@onekeyfe/hd-core 1.2.0-alpha.56 → 1.2.0-alpha.62

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 (262) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +0 -231
  2. package/__tests__/DeviceCommands.test.ts +0 -45
  3. package/__tests__/DeviceEventRegistration.test.ts +0 -6
  4. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +3 -198
  5. package/__tests__/connectSettings.test.ts +47 -5
  6. package/__tests__/device-initialize-timeout.test.ts +56 -0
  7. package/__tests__/device-lifecycle-events.test.ts +3 -105
  8. package/__tests__/device-state-contract.test.ts +0 -1
  9. package/__tests__/device-state-mapper.test.ts +1 -1
  10. package/__tests__/deviceSettings.test.ts +1 -1
  11. package/__tests__/firmware-update/check-all-firmware-release.test.ts +175 -0
  12. package/__tests__/firmware-update/firmware-artifact-source.test.ts +119 -0
  13. package/__tests__/firmware-update/firmware-host-binding.test.ts +70 -0
  14. package/__tests__/firmware-update/firmware-preparation-error.test.ts +27 -0
  15. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +200 -0
  16. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +13 -0
  17. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +294 -0
  18. package/__tests__/firmware-update/firmware-update-plan.test.ts +593 -0
  19. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +213 -0
  20. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +59 -31
  21. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +58 -0
  22. package/__tests__/firmware-update/firmware-upload-source.test.ts +70 -0
  23. package/__tests__/get-device-state.test.ts +12 -132
  24. package/__tests__/homescreen.test.ts +1 -41
  25. package/__tests__/kaspaSignTransaction.test.ts +26 -3
  26. package/__tests__/logBlockEvent.test.ts +1 -1
  27. package/__tests__/method-protocol-support.test.ts +0 -19
  28. package/__tests__/open-wallet-session.test.ts +37 -568
  29. package/__tests__/protocol-v2-unlock-policy.test.ts +35 -201
  30. package/__tests__/protocol-v2.test.ts +1046 -1388
  31. package/__tests__/protocolV2FileWrite.test.ts +0 -39
  32. package/__tests__/protocolV2UiInteraction.test.ts +0 -63
  33. package/__tests__/public-pro2-api-boundary.test.ts +0 -1
  34. package/__tests__/search-devices.test.ts +3 -12
  35. package/dist/api/BaseMethod.d.ts +1 -3
  36. package/dist/api/BaseMethod.d.ts.map +1 -1
  37. package/dist/api/CheckAllFirmwareRelease.d.ts +1 -2
  38. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  39. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  40. package/dist/api/FirmwareUpdateV2.d.ts +11 -0
  41. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  42. package/dist/api/FirmwareUpdateV3.d.ts +7 -3
  43. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  44. package/dist/api/FirmwareUpdateV4.d.ts +25 -11
  45. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  46. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  47. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  48. package/dist/api/SearchDevices.d.ts.map +1 -1
  49. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  50. package/dist/api/device/DeviceUnlock.d.ts +2 -2
  51. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  52. package/dist/api/device/DeviceUpdateBootloader.d.ts +5 -1
  53. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  54. package/dist/api/firmware/FirmwareArtifactSource.d.ts +25 -0
  55. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -0
  56. package/dist/api/firmware/FirmwareHostBinding.d.ts +6 -0
  57. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -0
  58. package/dist/api/firmware/FirmwarePreparationError.d.ts +3 -0
  59. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +1 -0
  60. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +9 -0
  61. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  62. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +3 -0
  63. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +1 -0
  64. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +25 -0
  65. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -0
  66. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +32 -0
  67. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -0
  68. package/dist/api/firmware/getBinary.d.ts +7 -0
  69. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  70. package/dist/api/firmware/progressThrottle.d.ts +3 -0
  71. package/dist/api/firmware/progressThrottle.d.ts.map +1 -0
  72. package/dist/api/firmware/updateBootloader.d.ts +2 -0
  73. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  74. package/dist/api/firmware/uploadFirmware.d.ts +9 -1
  75. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  76. package/dist/api/helpers/protocolV2FileWrite.d.ts +0 -2
  77. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  78. package/dist/api/index.d.ts +0 -2
  79. package/dist/api/index.d.ts.map +1 -1
  80. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  81. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  82. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  83. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  84. package/dist/core/deviceEventRegistration.d.ts +0 -2
  85. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  86. package/dist/core/index.d.ts.map +1 -1
  87. package/dist/data-manager/DataManager.d.ts +2 -4
  88. package/dist/data-manager/DataManager.d.ts.map +1 -1
  89. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  90. package/dist/device/Device.d.ts +6 -29
  91. package/dist/device/Device.d.ts.map +1 -1
  92. package/dist/device/DeviceCommands.d.ts +4 -4
  93. package/dist/device/DeviceCommands.d.ts.map +1 -1
  94. package/dist/device/DevicePool.d.ts.map +1 -1
  95. package/dist/events/device.d.ts +0 -4
  96. package/dist/events/device.d.ts.map +1 -1
  97. package/dist/events/logBlockEvent.d.ts.map +1 -1
  98. package/dist/events/ui-promise.d.ts +2 -4
  99. package/dist/events/ui-promise.d.ts.map +1 -1
  100. package/dist/events/ui-request.d.ts +2 -31
  101. package/dist/events/ui-request.d.ts.map +1 -1
  102. package/dist/events/ui-response.d.ts +2 -10
  103. package/dist/events/ui-response.d.ts.map +1 -1
  104. package/dist/index.d.ts +231 -214
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +3530 -2634
  107. package/dist/inject.d.ts +1 -6
  108. package/dist/inject.d.ts.map +1 -1
  109. package/dist/lowLevelInject.d.ts.map +1 -1
  110. package/dist/protocols/protocol-v2/features.d.ts +4 -0
  111. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  112. package/dist/protocols/protocol-v2/firmware.d.ts.map +1 -1
  113. package/dist/protocols/protocol-v2/lockedError.d.ts +2 -0
  114. package/dist/protocols/protocol-v2/lockedError.d.ts.map +1 -0
  115. package/dist/protocols/protocol-v2/uiInteraction.d.ts +4 -7
  116. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  117. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +3 -1
  118. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  119. package/dist/protocols/protocol-v2/unlockRetry.d.ts +10 -0
  120. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -0
  121. package/dist/protocols/protocol-v2/walletSession.d.ts +1 -6
  122. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  123. package/dist/topLevelInject.d.ts.map +1 -1
  124. package/dist/types/api/checkAllFirmwareRelease.d.ts +4 -2
  125. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  126. package/dist/types/api/deviceUnlock.d.ts +1 -5
  127. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  128. package/dist/types/api/deviceUpdateBootloader.d.ts +6 -0
  129. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  130. package/dist/types/api/export.d.ts +4 -2
  131. package/dist/types/api/export.d.ts.map +1 -1
  132. package/dist/types/api/firmwareUpdate.d.ts +70 -2
  133. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  134. package/dist/types/api/firmwareUpdateCapabilities.d.ts +9 -0
  135. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +1 -0
  136. package/dist/types/api/firmwareUpdatePlan.d.ts +28 -0
  137. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -0
  138. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +42 -0
  139. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +1 -0
  140. package/dist/types/api/index.d.ts +10 -7
  141. package/dist/types/api/index.d.ts.map +1 -1
  142. package/dist/types/api/protocolV2.d.ts +0 -3
  143. package/dist/types/api/protocolV2.d.ts.map +1 -1
  144. package/dist/types/device.d.ts.map +1 -1
  145. package/dist/types/params.d.ts +0 -1
  146. package/dist/types/params.d.ts.map +1 -1
  147. package/dist/types/settings.d.ts +13 -22
  148. package/dist/types/settings.d.ts.map +1 -1
  149. package/dist/utils/deviceFeaturesCompat.d.ts +1 -0
  150. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  151. package/dist/utils/deviceFeaturesUtils.d.ts +0 -4
  152. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  153. package/dist/utils/homescreen.d.ts +0 -4
  154. package/dist/utils/homescreen.d.ts.map +1 -1
  155. package/dist/utils/index.d.ts +1 -1
  156. package/dist/utils/index.d.ts.map +1 -1
  157. package/dist/utils/patch.d.ts +1 -1
  158. package/dist/utils/patch.d.ts.map +1 -1
  159. package/dist/utils/pro2Wallpaper.d.ts +0 -10
  160. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  161. package/package.json +4 -4
  162. package/src/api/BaseMethod.ts +10 -8
  163. package/src/api/CheckAllFirmwareRelease.ts +57 -54
  164. package/src/api/FirmwareUpdate.ts +15 -7
  165. package/src/api/FirmwareUpdateV2.ts +316 -126
  166. package/src/api/FirmwareUpdateV3.ts +265 -63
  167. package/src/api/FirmwareUpdateV4.ts +975 -584
  168. package/src/api/GetPassphraseState.ts +1 -7
  169. package/src/api/OpenWalletSession.ts +17 -70
  170. package/src/api/SearchDevices.ts +1 -3
  171. package/src/api/allnetwork/AllNetworkGetAddress.ts +21 -21
  172. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +11 -34
  173. package/src/api/device/DeviceUnlock.ts +3 -8
  174. package/src/api/device/DeviceUpdateBootloader.ts +122 -6
  175. package/src/api/firmware/FirmwareArtifactSource.ts +278 -0
  176. package/src/api/firmware/FirmwareHostBinding.ts +100 -0
  177. package/src/api/firmware/FirmwarePreparationError.ts +12 -0
  178. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +83 -16
  179. package/src/api/firmware/FirmwareUpdateCapabilities.ts +9 -0
  180. package/src/api/firmware/FirmwareUpdatePlan.ts +772 -0
  181. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +415 -0
  182. package/src/api/firmware/progressThrottle.ts +44 -0
  183. package/src/api/firmware/updateBootloader.ts +19 -4
  184. package/src/api/firmware/uploadFirmware.ts +144 -39
  185. package/src/api/helpers/protocolV2FileWrite.ts +7 -27
  186. package/src/api/index.ts +0 -2
  187. package/src/api/kaspa/KaspaSignTransaction.ts +12 -1
  188. package/src/api/protocol-v2/ProtocolInfoRequest.ts +1 -3
  189. package/src/api/protocol-v2/helpers.ts +0 -1
  190. package/src/core/deviceEventRegistration.ts +0 -4
  191. package/src/core/index.ts +83 -167
  192. package/src/data/messages/messages-protocol-v2.json +0 -25
  193. package/src/data-manager/DataManager.ts +39 -71
  194. package/src/data-manager/connectSettings.ts +11 -0
  195. package/src/device/Device.ts +43 -259
  196. package/src/device/DeviceCommands.ts +1 -7
  197. package/src/device/DevicePool.ts +2 -7
  198. package/src/events/device.ts +0 -4
  199. package/src/events/logBlockEvent.ts +0 -1
  200. package/src/events/ui-promise.ts +2 -4
  201. package/src/events/ui-request.ts +2 -36
  202. package/src/events/ui-response.ts +2 -12
  203. package/src/index.ts +5 -0
  204. package/src/inject.ts +24 -60
  205. package/src/lowLevelInject.ts +8 -7
  206. package/src/protocols/protocol-v2/features.ts +7 -10
  207. package/src/protocols/protocol-v2/firmware.ts +0 -2
  208. package/src/protocols/protocol-v2/lockedError.ts +10 -0
  209. package/src/protocols/protocol-v2/uiInteraction.ts +6 -46
  210. package/src/protocols/protocol-v2/unlockPolicy.ts +105 -1
  211. package/src/protocols/protocol-v2/unlockRetry.ts +109 -0
  212. package/src/protocols/protocol-v2/walletSession.ts +36 -214
  213. package/src/topLevelInject.ts +8 -7
  214. package/src/types/api/checkAllFirmwareRelease.ts +4 -2
  215. package/src/types/api/deviceUnlock.ts +1 -12
  216. package/src/types/api/deviceUpdateBootloader.ts +6 -0
  217. package/src/types/api/export.ts +18 -1
  218. package/src/types/api/firmwareUpdate.ts +76 -3
  219. package/src/types/api/firmwareUpdateCapabilities.ts +9 -0
  220. package/src/types/api/firmwareUpdatePlan.ts +38 -0
  221. package/src/types/api/firmwareUpdatePreparedPlan.ts +53 -0
  222. package/src/types/api/index.ts +14 -13
  223. package/src/types/api/protocolV2.ts +0 -13
  224. package/src/types/device.ts +0 -3
  225. package/src/types/params.ts +1 -7
  226. package/src/types/settings.ts +13 -42
  227. package/src/utils/deviceFeaturesCompat.ts +6 -0
  228. package/src/utils/deviceFeaturesUtils.ts +0 -8
  229. package/src/utils/homescreen.ts +6 -32
  230. package/src/utils/index.ts +1 -6
  231. package/src/utils/pro2Wallpaper.ts +8 -35
  232. package/__tests__/deviceUploadNft.test.ts +0 -293
  233. package/__tests__/pro2Nft.test.ts +0 -108
  234. package/__tests__/protocol-binding.test.ts +0 -89
  235. package/__tests__/protocol-v2-resources.test.ts +0 -286
  236. package/__tests__/protocol-v2-ui-lifecycle.test.ts +0 -91
  237. package/__tests__/uiProgressThrottle.test.ts +0 -74
  238. package/__tests__/uiPromiseRegistry.test.ts +0 -86
  239. package/dist/api/DetectDeviceConnectProtocol.d.ts +0 -7
  240. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +0 -1
  241. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +0 -31
  242. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +0 -1
  243. package/dist/core/uiPromiseRegistry.d.ts +0 -4
  244. package/dist/core/uiPromiseRegistry.d.ts.map +0 -1
  245. package/dist/protocols/protocol-v2/resources.d.ts +0 -30
  246. package/dist/protocols/protocol-v2/resources.d.ts.map +0 -1
  247. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +0 -22
  248. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +0 -1
  249. package/dist/types/api/detectDeviceConnectProtocol.d.ts +0 -4
  250. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +0 -1
  251. package/dist/utils/pro2Nft.d.ts +0 -31
  252. package/dist/utils/pro2Nft.d.ts.map +0 -1
  253. package/dist/utils/uiProgressThrottle.d.ts +0 -3
  254. package/dist/utils/uiProgressThrottle.d.ts.map +0 -1
  255. package/src/api/DetectDeviceConnectProtocol.ts +0 -18
  256. package/src/api/protocol-v2/DeviceUploadNft.ts +0 -189
  257. package/src/core/uiPromiseRegistry.ts +0 -41
  258. package/src/protocols/protocol-v2/resources.ts +0 -359
  259. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +0 -118
  260. package/src/types/api/detectDeviceConnectProtocol.ts +0 -6
  261. package/src/utils/pro2Nft.ts +0 -142
  262. package/src/utils/uiProgressThrottle.ts +0 -63
@@ -1,11 +1,10 @@
1
- import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { EFirmwareType, 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';
9
8
 
10
9
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
11
10
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -62,7 +61,6 @@ import { createCoreApi } from '../src/inject';
62
61
  import { Device, preloadSessionCache } from '../src/device/Device';
63
62
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
64
63
  import { DevicePool } from '../src/device/DevicePool';
65
- import { DEVICE } from '../src/events';
66
64
  import { UI_REQUEST } from '../src/events/ui-request';
67
65
  import {
68
66
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -76,15 +74,11 @@ import {
76
74
  requestProtocolV2ProtocolInfo,
77
75
  supportsProtocolV2Message,
78
76
  } from '../src/protocols/protocol-v2/features';
79
- import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
80
77
  import {
81
78
  getProtocolV2WalletSession,
82
79
  refreshProtocolV2DeviceStatus,
83
80
  } from '../src/protocols/protocol-v2/walletSession';
84
- import {
85
- createProtocolV2UnlockContext,
86
- runMethodWithUnlockPolicy,
87
- } from '../src/protocols/protocol-v2/unlockPolicyRunner';
81
+ import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
88
82
  import { BaseMethod } from '../src/api/BaseMethod';
89
83
  import {
90
84
  buildProtocolV1FeaturesPayload,
@@ -98,6 +92,7 @@ import {
98
92
  getMethodVersionRange,
99
93
  } from '../src/utils';
100
94
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
+ import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
101
96
  import {
102
97
  getPassphraseState,
103
98
  getPassphraseStateWithRefreshDeviceInfo,
@@ -246,7 +241,7 @@ describe('UploadPortfolio', () => {
246
241
  (method as any).device = device;
247
242
 
248
243
  method.init();
249
- await runMethodWithUnlockPolicy(method, device as any);
244
+ await runMethodWithUnlockRetry(method, device as any);
250
245
 
251
246
  expect(unlockDevice).not.toHaveBeenCalled();
252
247
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -422,16 +417,14 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
422
417
  }
423
418
  return d.features;
424
419
  };
425
- d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
426
- requestProtocolV2ProtocolInfo({
427
- commands: d.getCommands?.() ?? d.commands,
428
- timeoutMs,
429
- });
430
420
  d.probeProtocolV2RuntimeState ??= async (
431
421
  deviceInfo: ProtocolV2DeviceInfo,
432
422
  timeoutMs?: number
433
423
  ) => {
434
- const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
424
+ const protocolInfo = await requestProtocolV2ProtocolInfo({
425
+ commands: d.getCommands(),
426
+ timeoutMs,
427
+ });
435
428
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
436
429
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
437
430
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -788,9 +781,6 @@ describe('Protocol V2 feature adapter', () => {
788
781
  if (request === 'DeviceStatusGet') {
789
782
  return { type: 'DeviceStatus', message: { unlocked: true } };
790
783
  }
791
- if (request === 'DeviceSessionAskPassphrase') {
792
- return { type: 'Success', message: {} };
793
- }
794
784
  if (request === 'DeviceSessionGet') {
795
785
  return {
796
786
  type: 'DeviceSession',
@@ -815,7 +805,7 @@ describe('Protocol V2 feature adapter', () => {
815
805
  ).resolves.toEqual({
816
806
  passphraseState: 'state-1',
817
807
  newSession: 'session-1',
818
- unlockedAttachPin: false,
808
+ unlockedAttachPin: undefined,
819
809
  resumed: false,
820
810
  });
821
811
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -824,13 +814,7 @@ describe('Protocol V2 feature adapter', () => {
824
814
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
825
815
  type: DeviceSessionPinType.Main,
826
816
  });
827
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
828
- passphrase: '',
829
- on_device: false,
830
- });
831
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
832
- seed_domains: [],
833
- });
817
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
834
818
  device.passphraseState = 'state-1';
835
819
  expect(device.getInternalState()).toBe('session-1');
836
820
  });
@@ -863,7 +847,6 @@ describe('Protocol V2 feature adapter', () => {
863
847
  },
864
848
  };
865
849
  }
866
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
867
850
  if (request === 'DeviceSessionGet') {
868
851
  return {
869
852
  message: {
@@ -880,16 +863,9 @@ describe('Protocol V2 feature adapter', () => {
880
863
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
881
864
 
882
865
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
883
- expect(
884
- typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
885
- ).toHaveLength(1);
886
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
887
- seed_domains: [],
888
- });
866
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
889
867
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
890
868
  session_id: 'standard-session',
891
- btc_test_address: 'standard-state',
892
- seed_domains: [],
893
869
  });
894
870
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
895
871
  passphraseState: 'standard-state',
@@ -931,7 +907,6 @@ describe('Protocol V2 feature adapter', () => {
931
907
  },
932
908
  };
933
909
  }
934
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
935
910
  if (request === 'DeviceSessionAskPin') {
936
911
  return { type: 'Success', message: { message: 'PIN verified' } };
937
912
  }
@@ -1026,7 +1001,6 @@ describe('Protocol V2 feature adapter', () => {
1026
1001
  if (request === 'DeviceStatusGet') {
1027
1002
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1028
1003
  }
1029
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1030
1004
  if (request === 'DeviceSessionGet') {
1031
1005
  sessionGetCalls += 1;
1032
1006
  return {
@@ -1253,9 +1227,7 @@ describe('Protocol V2 feature adapter', () => {
1253
1227
  passphrase: 'host hidden wallet',
1254
1228
  on_device: false,
1255
1229
  });
1256
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1257
- seed_domains: [],
1258
- });
1230
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1259
1231
  });
1260
1232
 
1261
1233
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1363,8 +1335,6 @@ describe('Protocol V2 feature adapter', () => {
1363
1335
 
1364
1336
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1365
1337
  session_id: 'session-a',
1366
- btc_test_address: 'state-a',
1367
- seed_domains: [],
1368
1338
  });
1369
1339
  expect(device.getInternalState()).toBe('session-b');
1370
1340
  });
@@ -1432,15 +1402,7 @@ describe('Protocol V2 feature adapter', () => {
1432
1402
  });
1433
1403
  expect(typedCall.mock.calls).toEqual([
1434
1404
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1435
- [
1436
- 'DeviceSessionGet',
1437
- 'DeviceSession',
1438
- {
1439
- session_id: 'session-a',
1440
- btc_test_address: 'state-a',
1441
- seed_domains: [],
1442
- },
1443
- ],
1405
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1444
1406
  ]);
1445
1407
  expect(promptPassphrase).not.toHaveBeenCalled();
1446
1408
  expect(device.getInternalState()).toBeUndefined();
@@ -1488,7 +1450,7 @@ describe('Protocol V2 feature adapter', () => {
1488
1450
  { passphrase: 'host hidden wallet', on_device: false },
1489
1451
  ],
1490
1452
  ['DeviceStatusGet', 'DeviceStatus', {}],
1491
- ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1453
+ ['DeviceSessionGet', 'DeviceSession', {}],
1492
1454
  ]);
1493
1455
  });
1494
1456
 
@@ -1591,73 +1553,6 @@ describe('Protocol V2 feature adapter', () => {
1591
1553
  expect(device.getInternalState()).toBeUndefined();
1592
1554
  });
1593
1555
 
1594
- test.each([false, true])(
1595
- 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1596
- async lockFails => {
1597
- const deviceId = 'attach-mismatch-device';
1598
- const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1599
- (device as any).features = normalizeProtocolV2Features(
1600
- { ...descriptor, protocolType: 'V2' } as any,
1601
- {
1602
- status: {
1603
- device_id: deviceId,
1604
- unlocked: true,
1605
- passphrase_enabled: true,
1606
- attach_to_pin_enabled: true,
1607
- unlocked_by_attach_to_pin: false,
1608
- },
1609
- }
1610
- );
1611
- device.passphraseState = 'expected-state';
1612
-
1613
- const typedCall = jest.fn((request: string) => {
1614
- if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1615
- if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1616
- if (request === 'DeviceStatusGet') {
1617
- return {
1618
- message: {
1619
- device_id: deviceId,
1620
- unlocked: true,
1621
- passphrase_enabled: true,
1622
- attach_to_pin_enabled: true,
1623
- unlocked_by_attach_to_pin: true,
1624
- },
1625
- };
1626
- }
1627
- if (request === 'DeviceSessionGet') {
1628
- return {
1629
- message: {
1630
- btc_test_address: 'wrong-attach-state',
1631
- session_id: 'wrong-attach-session',
1632
- },
1633
- };
1634
- }
1635
- if (request === 'LockDevice') {
1636
- if (lockFails) throw new Error('Lock unsupported');
1637
- return { message: { message: 'Device locked' } };
1638
- }
1639
- throw new Error(`Unexpected request: ${request}`);
1640
- });
1641
- const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1642
- (device as any).commands = { typedCall, promptPassphrase };
1643
-
1644
- await expect(
1645
- getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1646
- ).rejects.toMatchObject({
1647
- errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1648
- });
1649
-
1650
- expect(promptPassphrase).not.toHaveBeenCalled();
1651
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1652
- btc_test_address: 'expected-state',
1653
- seed_domains: [],
1654
- });
1655
- expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1656
- expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1657
- expect(device.getInternalState()).toBeUndefined();
1658
- }
1659
- );
1660
-
1661
1556
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1662
1557
  const device = Device.fromDescriptor({
1663
1558
  id: 'cache-device-attach-recovery',
@@ -1714,17 +1609,7 @@ describe('Protocol V2 feature adapter', () => {
1714
1609
  }),
1715
1610
  { cancelDeviceOnReject: false }
1716
1611
  );
1717
- expect(unlockDevice).toHaveBeenCalledWith(
1718
- DeviceSessionPinType.AttachToPin,
1719
- expect.objectContaining({
1720
- emitUiEvent: false,
1721
- interaction: expect.objectContaining({
1722
- phase: 'pin',
1723
- transition: 'start',
1724
- protocol: 'V2',
1725
- }),
1726
- })
1727
- );
1612
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1728
1613
  expect(device.getInternalState()).toBe('new-attach-session');
1729
1614
  });
1730
1615
 
@@ -2008,10 +1893,12 @@ describe('Protocol V2 feature adapter', () => {
2008
1893
  expect(getFeatures).not.toHaveBeenCalled();
2009
1894
  });
2010
1895
 
2011
- test('uses the cached wallet address when the local session is missing', async () => {
1896
+ test('marks an interactive cache miss as a wallet-session recovery', async () => {
2012
1897
  const typedCall = jest
2013
1898
  .fn()
2014
1899
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
2015
1902
  .mockResolvedValueOnce({
2016
1903
  message: {
2017
1904
  btc_test_address: 'expected-state',
@@ -2039,11 +1926,16 @@ describe('Protocol V2 feature adapter', () => {
2039
1926
  await expect(
2040
1927
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
2041
1928
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2042
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2043
- btc_test_address: 'expected-state',
2044
- seed_domains: [],
2045
- });
2046
- expect(promptPassphrase).not.toHaveBeenCalled();
1929
+ expect(promptPassphrase).toHaveBeenCalledWith(
1930
+ {
1931
+ existsAttachPinUser: false,
1932
+ deviceOnly: false,
1933
+ source: 'wallet-session-coordinator',
1934
+ reason: 'session-recovery',
1935
+ expectedPassphraseState: 'expected-state',
1936
+ },
1937
+ { cancelDeviceOnReject: false }
1938
+ );
2047
1939
  });
2048
1940
 
2049
1941
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2109,15 +2001,8 @@ describe('Protocol V2 feature adapter', () => {
2109
2001
  true
2110
2002
  );
2111
2003
  expect(typedCall.mock.calls).toEqual([
2112
- [
2113
- 'DeviceSessionGet',
2114
- 'DeviceSession',
2115
- {
2116
- session_id: 'session-pro2-app',
2117
- btc_test_address: 'state-pro2-app',
2118
- seed_domains: [],
2119
- },
2120
- ],
2004
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2121
2006
  ]);
2122
2007
  });
2123
2008
 
@@ -2306,9 +2191,7 @@ describe('Protocol V2 feature adapter', () => {
2306
2191
  passphrase: 'host hidden wallet',
2307
2192
  on_device: false,
2308
2193
  });
2309
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2310
- seed_domains: [],
2311
- });
2194
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2312
2195
  });
2313
2196
 
2314
2197
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2386,83 +2269,12 @@ describe('Protocol V2 feature adapter', () => {
2386
2269
  );
2387
2270
 
2388
2271
  expect(device.getInternalState()).toBeUndefined();
2389
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2390
- btc_test_address: 'expected-state',
2391
- seed_domains: [],
2392
- });
2393
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2272
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2394
2273
  passphrase: 'host hidden wallet',
2395
2274
  on_device: false,
2396
2275
  });
2397
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2398
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2399
- seed_domains: [],
2400
- });
2401
- });
2402
-
2403
- test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
2404
- const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2405
- const typedCall = jest.fn((request: string) => {
2406
- if (request === 'ProtocolInfoRequest') {
2407
- return { message: { version: 2 } };
2408
- }
2409
- if (request === 'DeviceStatusGet') {
2410
- return {
2411
- message: {
2412
- device_id: 'attach-wallet-device',
2413
- unlocked: true,
2414
- passphrase_enabled: true,
2415
- attach_to_pin_enabled: true,
2416
- unlocked_by_attach_to_pin: true,
2417
- },
2418
- };
2419
- }
2420
- if (request === 'DeviceSessionAskPin' || request === 'DeviceSessionAskPassphrase') {
2421
- return { message: { message: 'Wallet selected' } };
2422
- }
2423
- if (request === 'DeviceSessionGet') {
2424
- return {
2425
- message: {
2426
- session_id: 'main-wallet-session',
2427
- btc_test_address: 'main-wallet-state',
2428
- },
2429
- };
2430
- }
2431
- if (request === 'LockDevice') {
2432
- return { message: { message: 'Device locked' } };
2433
- }
2434
- throw new Error(`Unexpected request: ${request}`);
2435
- });
2436
-
2437
- (device as any).features = normalizeProtocolV2Features(
2438
- { ...descriptor, protocolType: 'V2' } as any,
2439
- {
2440
- status: {
2441
- device_id: 'attach-wallet-device',
2442
- unlocked: true,
2443
- passphrase_enabled: true,
2444
- attach_to_pin_enabled: true,
2445
- // 模拟缓存滞后:设备状态刷新后才确认当前由 Attach PIN 解锁。
2446
- unlocked_by_attach_to_pin: false,
2447
- },
2448
- }
2449
- );
2450
- (device as any).commands = { typedCall };
2451
- const clearInternalState = jest.spyOn(device, 'clearInternalState');
2452
-
2453
- await expect(device.checkPassphraseStateSafety(undefined, true, false)).rejects.toMatchObject({
2454
- errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2455
- });
2456
-
2457
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2458
- expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
2459
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2460
- expect(typedCall).not.toHaveBeenCalledWith(
2461
- 'DeviceSessionAskPassphrase',
2462
- 'Success',
2463
- expect.anything()
2464
- );
2465
- expect(clearInternalState).toHaveBeenCalledTimes(1);
2276
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
+ expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2466
2278
  });
2467
2279
 
2468
2280
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2495,22 +2307,10 @@ describe('Protocol V2 feature adapter', () => {
2495
2307
  await expect(
2496
2308
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2497
2309
  ).resolves.toBe(true);
2498
- expect(typedCall).toHaveBeenCalledTimes(5);
2310
+ expect(typedCall).toHaveBeenCalledTimes(4);
2499
2311
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2500
2312
  eventless_wallet_session: true,
2501
2313
  });
2502
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2503
- seed_domains: [],
2504
- });
2505
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2506
- passphrase: '',
2507
- on_device: false,
2508
- });
2509
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2510
- expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
2511
- 2
2512
- );
2513
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2514
2314
  });
2515
2315
 
2516
2316
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2581,7 +2381,7 @@ describe('Protocol V2 feature adapter', () => {
2581
2381
  uuid: 'CACHED-SERIAL',
2582
2382
  deviceId: null,
2583
2383
  bleName: 'Cached BLE',
2584
- name: 'Cached BLE',
2384
+ name: 'Cached Label',
2585
2385
  label: 'Cached Label',
2586
2386
  deviceType: 'pro2',
2587
2387
  });
@@ -2608,7 +2408,7 @@ describe('Protocol V2 feature adapter', () => {
2608
2408
  expect(device.features?.label).toBeNull();
2609
2409
  });
2610
2410
 
2611
- test('uses the BLE name for discovery while preserving the Protocol V2 label', () => {
2411
+ test('prefers the real Protocol V2 label over the BLE name for display', () => {
2612
2412
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2613
2413
  (device as any).features = {
2614
2414
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
@@ -2616,10 +2416,9 @@ describe('Protocol V2 feature adapter', () => {
2616
2416
  bleName: 'Pro2 6136',
2617
2417
  };
2618
2418
 
2619
- expect(device.getCurrentDisplayName()).toBe('My Pro 2');
2620
2419
  expect(device.toMessageObject()).toMatchObject({
2621
2420
  bleName: 'Pro2 6136',
2622
- name: 'Pro2 6136',
2421
+ name: 'My Pro 2',
2623
2422
  label: 'My Pro 2',
2624
2423
  });
2625
2424
  });
@@ -2673,6 +2472,10 @@ describe('Protocol V2 feature adapter', () => {
2673
2472
  hw: true,
2674
2473
  fw: true,
2675
2474
  coprocessor: true,
2475
+ se1: true,
2476
+ se2: true,
2477
+ se3: true,
2478
+ se4: true,
2676
2479
  },
2677
2480
  types: {
2678
2481
  version: true,
@@ -2864,6 +2667,10 @@ describe('Protocol V2 feature adapter', () => {
2864
2667
  hw: true,
2865
2668
  fw: true,
2866
2669
  coprocessor: true,
2670
+ se1: true,
2671
+ se2: true,
2672
+ se3: true,
2673
+ se4: true,
2867
2674
  },
2868
2675
  types: {
2869
2676
  version: true,
@@ -2874,9 +2681,7 @@ describe('Protocol V2 feature adapter', () => {
2874
2681
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2875
2682
  }
2876
2683
  );
2877
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2878
- eventless_wallet_session: true,
2879
- });
2684
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2880
2685
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2881
2686
  });
2882
2687
 
@@ -2914,9 +2719,7 @@ describe('Protocol V2 feature adapter', () => {
2914
2719
  }),
2915
2720
  expect.anything()
2916
2721
  );
2917
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2918
- eventless_wallet_session: true,
2919
- });
2722
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2920
2723
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2921
2724
  expect(device.features).toMatchObject({
2922
2725
  deviceId: null,
@@ -3182,6 +2985,10 @@ describe('Protocol V2 feature adapter', () => {
3182
2985
  fw: { application: { version: '1.2.4' } },
3183
2986
  },
3184
2987
  })
2988
+ .mockResolvedValueOnce({
2989
+ type: 'ProtocolInfo',
2990
+ message: protocolV2ApplicationInfo,
2991
+ })
3185
2992
  .mockResolvedValueOnce({
3186
2993
  type: 'DeviceStatus',
3187
2994
  message: { init_states: true, unlocked: true },
@@ -3203,7 +3010,7 @@ describe('Protocol V2 feature adapter', () => {
3203
3010
  firmwareVersion: '1.2.4',
3204
3011
  passphraseProtection: null,
3205
3012
  });
3206
- expect(typedCall).toHaveBeenCalledTimes(5);
3013
+ expect(typedCall).toHaveBeenCalledTimes(6);
3207
3014
  expect(typedCall).toHaveBeenNthCalledWith(
3208
3015
  4,
3209
3016
  'DeviceInfoGet',
@@ -3213,6 +3020,10 @@ describe('Protocol V2 feature adapter', () => {
3213
3020
  hw: true,
3214
3021
  fw: true,
3215
3022
  coprocessor: true,
3023
+ se1: true,
3024
+ se2: true,
3025
+ se3: true,
3026
+ se4: true,
3216
3027
  },
3217
3028
  types: {
3218
3029
  version: true,
@@ -3313,23 +3124,9 @@ describe('Protocol V2 feature adapter', () => {
3313
3124
  status: { init_states: true },
3314
3125
  }
3315
3126
  );
3316
- const pinOnDevice = jest.fn();
3317
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3318
3127
 
3319
3128
  const features = await device.unlockDevice();
3320
3129
 
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
- );
3333
3130
  expect(typedCall.mock.calls).toEqual([
3334
3131
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3335
3132
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3375,12 +3172,9 @@ describe('Protocol V2 feature adapter', () => {
3375
3172
  throw new Error(`Unexpected request: ${requestType}`);
3376
3173
  });
3377
3174
  (device as any).commands = { typedCall };
3378
- const pinOnDevice = jest.fn();
3379
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3380
3175
 
3381
- await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3176
+ await device.unlockDevice();
3382
3177
 
3383
- expect(pinOnDevice).not.toHaveBeenCalled();
3384
3178
  expect(typedCall.mock.calls).toEqual([
3385
3179
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3386
3180
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3783,15 +3577,6 @@ describe('API compatibility handling', () => {
3783
3577
 
3784
3578
  describe('Protocol V2 firmware update targets', () => {
3785
3579
  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
- });
3795
3580
 
3796
3581
  const buildOkppHeader = ({
3797
3582
  type = 'RESC',
@@ -3916,7 +3701,7 @@ describe('Protocol V2 firmware update targets', () => {
3916
3701
  specific: true,
3917
3702
  },
3918
3703
  },
3919
- { timeoutMs: 30000 }
3704
+ { timeoutMs: 5000 }
3920
3705
  );
3921
3706
  expect(typedCall).toHaveBeenCalledTimes(3);
3922
3707
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
@@ -3932,7 +3717,7 @@ describe('Protocol V2 firmware update targets', () => {
3932
3717
  });
3933
3718
  });
3934
3719
 
3935
- test('enters Protocol V2 bootloader before upload', async () => {
3720
+ test('delegates Protocol V2 mode transition to the phase executor', async () => {
3936
3721
  const method = new FirmwareUpdateV4({
3937
3722
  id: 1,
3938
3723
  payload: {
@@ -3967,7 +3752,7 @@ describe('Protocol V2 firmware update targets', () => {
3967
3752
 
3968
3753
  await method.run();
3969
3754
 
3970
- expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3755
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3971
3756
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3972
3757
  fwBinaryMap: [
3973
3758
  {
@@ -3978,69 +3763,6 @@ describe('Protocol V2 firmware update targets', () => {
3978
3763
  ],
3979
3764
  bootloaderBinary: null,
3980
3765
  });
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']);
4044
3766
  });
4045
3767
 
4046
3768
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
@@ -4092,33 +3814,6 @@ describe('Protocol V2 firmware update targets', () => {
4092
3814
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4093
3815
  });
4094
3816
 
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
-
4122
3817
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
4123
3818
  const method = new FirmwareUpdateV4({
4124
3819
  id: 1,
@@ -4171,9 +3866,6 @@ describe('Protocol V2 firmware update targets', () => {
4171
3866
  },
4172
3867
  });
4173
3868
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4174
- const prepareResourceBundles = jest
4175
- .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4176
- .mockResolvedValue(undefined);
4177
3869
  (method as any).protocolV2Reboot = jest.fn();
4178
3870
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
4179
3871
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -4186,7 +3878,6 @@ describe('Protocol V2 firmware update targets', () => {
4186
3878
 
4187
3879
  await method.run();
4188
3880
 
4189
- expect(prepareResourceBundles).not.toHaveBeenCalled();
4190
3881
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4191
3882
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4192
3883
  bootloaderBinary: null,
@@ -4269,10 +3960,7 @@ describe('Protocol V2 firmware update targets', () => {
4269
3960
  getCommands: () => commands,
4270
3961
  mainId: 'usb-path',
4271
3962
  });
4272
- const cachedDevice = {
4273
- getConnectId: () => 'usb-path',
4274
- getCurrentSerialNo: () => '',
4275
- };
3963
+ const cachedDevice = { getConnectId: () => 'usb-path' };
4276
3964
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4277
3965
  devices: {},
4278
3966
  deviceList: [cachedDevice],
@@ -4289,191 +3977,15 @@ describe('Protocol V2 firmware update targets', () => {
4289
3977
  expect(initialize).not.toHaveBeenCalled();
4290
3978
  });
4291
3979
 
4292
- test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
3980
+ test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4293
3981
  const method = new FirmwareUpdateV4({
4294
3982
  id: 1,
4295
3983
  payload: {
4296
3984
  method: 'firmwareUpdateV4',
4297
3985
  },
4298
3986
  });
4299
- const commands = { disposed: true, mainId: '' };
4300
- const updateFromCache = jest.fn();
4301
- const device = stubDevice({
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);
3987
+ const commands = { disposed: false, mainId: 'usb-session' };
3988
+ const acquire = jest.fn().mockResolvedValue(undefined);
4477
3989
  const device = stubDevice({
4478
3990
  originalDescriptor: {
4479
3991
  id: 'usb-id',
@@ -4559,9 +4071,7 @@ describe('Protocol V2 firmware update targets', () => {
4559
4071
  });
4560
4072
  method.postTipMessage = jest.fn();
4561
4073
  (method as any).reconnectProtocolV2Device = 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);
4074
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4565
4075
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4566
4076
  message: 'Device rebooted successfully',
4567
4077
  });
@@ -4570,29 +4080,9 @@ describe('Protocol V2 firmware update targets', () => {
4570
4080
 
4571
4081
  expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4572
4082
  expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4573
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4574
4083
  expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4575
4084
  });
4576
4085
 
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
-
4596
4086
  test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4597
4087
  const method = new FirmwareUpdateV4({
4598
4088
  id: 1,
@@ -4635,27 +4125,6 @@ describe('Protocol V2 firmware update targets', () => {
4635
4125
  });
4636
4126
  });
4637
4127
 
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
-
4659
4128
  test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4660
4129
  const method = new FirmwareUpdateV4({
4661
4130
  id: 1,
@@ -4666,9 +4135,9 @@ describe('Protocol V2 firmware update targets', () => {
4666
4135
  const typedCall = jest
4667
4136
  .fn()
4668
4137
  .mockRejectedValue(
4669
- new DOMException(
4670
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4671
- 'NotFoundError'
4138
+ Object.assign(
4139
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
+ { name: 'NotFoundError' }
4672
4141
  )
4673
4142
  );
4674
4143
 
@@ -4746,7 +4215,7 @@ describe('Protocol V2 firmware update targets', () => {
4746
4215
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4747
4216
  ]);
4748
4217
 
4749
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4218
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4750
4219
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4751
4220
  'DeviceFirmwareUpdateStatusGet',
4752
4221
  'DeviceFirmwareUpdateStatusGet',
@@ -4784,46 +4253,6 @@ describe('Protocol V2 firmware update targets', () => {
4784
4253
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4785
4254
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4786
4255
  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');
4827
4256
  });
4828
4257
 
4829
4258
  test.each([
@@ -4889,6 +4318,77 @@ describe('Protocol V2 firmware update targets', () => {
4889
4318
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4890
4319
  });
4891
4320
 
4321
+ test('rejects a finished target whose payload version conflicts with the plan', () => {
4322
+ const method = new FirmwareUpdateV4({
4323
+ id: 1,
4324
+ payload: {
4325
+ method: 'firmwareUpdateV4',
4326
+ platform: 'desktop',
4327
+ expectedTargetVersions: {
4328
+ app_v1: '2.0.0',
4329
+ },
4330
+ },
4331
+ });
4332
+ method.init();
4333
+ method.postProgressMessage = jest.fn();
4334
+
4335
+ expect(() =>
4336
+ (method as any).assertProtocolV2TargetStatus(
4337
+ [{ target_id: 4, status: 2, payload_version: 0x010000 }],
4338
+ new Set([4])
4339
+ )
4340
+ ).toThrow('expected 131072');
4341
+ });
4342
+
4343
+ test('does not fail a completed multi-app install when final app versions are unobservable', () => {
4344
+ const method = new FirmwareUpdateV4({
4345
+ id: 1,
4346
+ payload: {
4347
+ method: 'firmwareUpdateV4',
4348
+ platform: 'desktop',
4349
+ targetsToUpdate: ['app_v1', 'app_v2'],
4350
+ expectedTargetVersions: {
4351
+ app_v1: '1.0.0',
4352
+ app_v2: '2.0.0',
4353
+ },
4354
+ },
4355
+ });
4356
+ method.init();
4357
+ (method as any).protocolV2LatestFinalFeatures = {
4358
+ major_version: 3,
4359
+ minor_version: 0,
4360
+ patch_version: 0,
4361
+ };
4362
+ (method as any).protocolV2FinalStatusVerified = true;
4363
+
4364
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
4365
+ });
4366
+
4367
+ test('rejects unobservable final versions after the status endpoint fallback', () => {
4368
+ const method = new FirmwareUpdateV4({
4369
+ id: 1,
4370
+ payload: {
4371
+ method: 'firmwareUpdateV4',
4372
+ platform: 'desktop',
4373
+ targetsToUpdate: ['app_v1', 'app_v2'],
4374
+ expectedTargetVersions: {
4375
+ app_v1: '1.0.0',
4376
+ app_v2: '2.0.0',
4377
+ },
4378
+ },
4379
+ });
4380
+ method.init();
4381
+ (method as any).protocolV2LatestFinalFeatures = {
4382
+ major_version: 3,
4383
+ minor_version: 0,
4384
+ patch_version: 0,
4385
+ };
4386
+
4387
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
4388
+ 'has no observable final version after status fallback'
4389
+ );
4390
+ });
4391
+
4892
4392
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4893
4393
  const method = new FirmwareUpdateV4({
4894
4394
  id: 1,
@@ -4931,152 +4431,7 @@ describe('Protocol V2 firmware update targets', () => {
4931
4431
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4932
4432
  });
4933
4433
 
4934
- test('fails clearly when a requested target stays absent from the full status dump', async () => {
4935
- const method = new FirmwareUpdateV4({
4936
- id: 1,
4937
- payload: {
4938
- method: 'firmwareUpdateV4',
4939
- },
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);
4960
- method.postProgressMessage = jest.fn();
4961
-
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', () => {
4434
+ test('uses SDK decoded enum names for Protocol V2 install polling', () => {
5080
4435
  const method = new FirmwareUpdateV4({
5081
4436
  id: 1,
5082
4437
  payload: {
@@ -5110,7 +4465,7 @@ describe('Protocol V2 firmware update targets', () => {
5110
4465
  ).toBe(true);
5111
4466
 
5112
4467
  (method.postProgressMessage as jest.Mock).mockClear();
5113
- expect(
4468
+ expect(() =>
5114
4469
  (method as any).assertProtocolV2TargetStatus(
5115
4470
  [
5116
4471
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -5118,8 +4473,8 @@ describe('Protocol V2 firmware update targets', () => {
5118
4473
  ],
5119
4474
  new Set([4, 10])
5120
4475
  )
5121
- ).toBe(false);
5122
- expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4476
+ ).toThrow('Protocol V2 install status conflicts with target 4');
4477
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
5123
4478
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5124
4479
 
5125
4480
  expect(
@@ -5208,7 +4563,7 @@ describe('Protocol V2 firmware update targets', () => {
5208
4563
  path: true,
5209
4564
  },
5210
4565
  },
5211
- { timeoutMs: 15000 }
4566
+ { timeoutMs: 5000 }
5212
4567
  );
5213
4568
  });
5214
4569
 
@@ -5223,10 +4578,14 @@ describe('Protocol V2 firmware update targets', () => {
5223
4578
  const writtenPaths: string[] = [];
5224
4579
  method.postTipMessage = jest.fn();
5225
4580
  method.postProgressMessage = jest.fn();
5226
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4581
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
5227
4582
  writtenPaths.push(params.filePath);
5228
- return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4583
+ return Number(params.processedSize ?? 0) + Number(params.source.size);
5229
4584
  });
4585
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4586
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4587
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
4588
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5230
4589
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5231
4590
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5232
4591
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5266,10 +4625,12 @@ describe('Protocol V2 firmware update targets', () => {
5266
4625
  'vol0:/coprocessor.bin',
5267
4626
  1
5268
4627
  );
5269
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
5270
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4628
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
4629
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
4630
+ targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
4631
+ });
4632
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
5271
4633
  targets: [
5272
- { target_id: 3, path: 'vol0:/bootloader.bin' },
5273
4634
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
5274
4635
  { target_id: 7, path: 'vol0:/se01.bin' },
5275
4636
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -5279,7 +4640,7 @@ describe('Protocol V2 firmware update targets', () => {
5279
4640
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
5280
4641
  });
5281
4642
 
5282
- test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4643
+ test('announces separate resource and component transfer phases', async () => {
5283
4644
  const method = new FirmwareUpdateV4({
5284
4645
  id: 1,
5285
4646
  payload: {
@@ -5289,11 +4650,14 @@ describe('Protocol V2 firmware update targets', () => {
5289
4650
 
5290
4651
  method.postTipMessage = jest.fn();
5291
4652
  method.postProgressMessage = jest.fn();
5292
- (method as any).protocolV2CommonUpdateProcess = jest
4653
+ (method as any).protocolV2SourceUpdateProcess = jest
5293
4654
  .fn()
5294
4655
  .mockImplementation(params =>
5295
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4656
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5296
4657
  );
4658
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4659
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4660
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5297
4661
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5298
4662
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5299
4663
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5313,21 +4677,22 @@ describe('Protocol V2 firmware update targets', () => {
5313
4677
  {
5314
4678
  fileName: 'application_p1.bin',
5315
4679
  binary: new Uint8Array([3]).buffer,
5316
- targetId: 3,
4680
+ targetId: 4,
5317
4681
  },
5318
4682
  ],
5319
4683
  });
5320
4684
 
5321
- expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4685
+ expect(method.postTipMessage).toHaveBeenCalledTimes(3);
5322
4686
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5323
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5324
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4687
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5325
4690
  1,
5326
- expect.objectContaining({ processedSize: 0, totalSize: 3 })
4691
+ expect.objectContaining({ processedSize: 0, totalSize: 2 })
5327
4692
  );
5328
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4693
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5329
4694
  2,
5330
- expect.objectContaining({ processedSize: 2, totalSize: 3 })
4695
+ expect.objectContaining({ processedSize: 0, totalSize: 1 })
5331
4696
  );
5332
4697
  });
5333
4698
 
@@ -5341,7 +4706,8 @@ describe('Protocol V2 firmware update targets', () => {
5341
4706
 
5342
4707
  method.postTipMessage = jest.fn();
5343
4708
  method.postProgressMessage = jest.fn();
5344
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4709
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5345
4711
  (method as any).device = stubDevice({
5346
4712
  getCommands: () => ({
5347
4713
  typedCall: jest.fn().mockResolvedValue({
@@ -5387,11 +4753,14 @@ describe('Protocol V2 firmware update targets', () => {
5387
4753
 
5388
4754
  method.postTipMessage = jest.fn();
5389
4755
  method.postProgressMessage = jest.fn();
5390
- (method as any).protocolV2CommonUpdateProcess = jest
4756
+ (method as any).protocolV2SourceUpdateProcess = jest
5391
4757
  .fn()
5392
4758
  .mockImplementation(params =>
5393
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4759
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5394
4760
  );
4761
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4762
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4763
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5395
4764
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5396
4765
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5397
4766
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5606,42 +4975,33 @@ describe('Protocol V2 firmware update targets', () => {
5606
4975
  getFirmwareLatestReleaseSpy.mockRestore();
5607
4976
  });
5608
4977
 
5609
- test('does not download Pro2 firmware components that App did not select', async () => {
4978
+ test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5610
4979
  const method = new FirmwareUpdateV4({
5611
4980
  id: 1,
5612
4981
  payload: {
5613
4982
  method: 'firmwareUpdateV4',
5614
4983
  platform: 'web',
4984
+ targetsToUpdate: ['resource'],
5615
4985
  },
5616
4986
  });
5617
4987
  method.init();
5618
4988
 
5619
- const getSysResourceBinarySpy = jest
5620
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5621
- .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5622
4989
  const getFirmwareLatestReleaseSpy = jest
5623
4990
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5624
4991
  .mockReturnValue({
5625
4992
  required: false,
5626
4993
  version: [1, 0, 0],
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',
5635
- },
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',
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),
5643
5003
  },
5644
- },
5004
+ ],
5645
5005
  fingerprint: '',
5646
5006
  changelog: {
5647
5007
  'zh-CN': '',
@@ -5649,135 +5009,435 @@ describe('Protocol V2 firmware update targets', () => {
5649
5009
  },
5650
5010
  });
5651
5011
 
5652
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5012
+ const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5653
5013
  deviceType: 'pro2',
5654
5014
  firmwareVersion: '1.0.0',
5655
- bootloaderVersion: '1.0.0',
5656
5015
  capabilities: [],
5657
5016
  });
5658
5017
 
5659
- expect(remoteBinaries).toEqual({
5660
- bootloaderBinary: null,
5661
- fwBinaryMap: [],
5662
- installItems: [],
5663
- });
5664
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5018
+ expect(bundles).toEqual([
5019
+ {
5020
+ name: 'images',
5021
+ binary: expect.any(ArrayBuffer),
5022
+ devicePath: 'vol0:/resource/images/images.okpkg',
5023
+ url: 'https://example.com/images.okpkg',
5024
+ version: [2, 0, 0],
5025
+ payloadHash: 'ab'.repeat(64),
5026
+ headerHash: 'cd'.repeat(64),
5027
+ },
5028
+ ]);
5029
+ expect(bundles[0].binary.byteLength).toBe(0);
5665
5030
 
5666
- getSysResourceBinarySpy.mockRestore();
5667
5031
  getFirmwareLatestReleaseSpy.mockRestore();
5668
5032
  });
5669
5033
 
5670
- test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5034
+ test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5671
5035
  const method = new FirmwareUpdateV4({
5672
5036
  id: 1,
5673
5037
  payload: {
5674
5038
  method: 'firmwareUpdateV4',
5675
5039
  platform: 'web',
5040
+ targetsToUpdate: ['resource'],
5676
5041
  },
5677
5042
  });
5678
5043
  method.init();
5679
5044
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5680
5045
 
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,
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': '',
5688
5064
  },
5689
- ],
5690
- };
5065
+ });
5691
5066
 
5692
5067
  (method as any).device = stubDevice({
5693
5068
  originalDescriptor: { protocolType: 'V2' },
5694
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5069
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5695
5070
  });
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',
5071
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
+ bootloaderBinary: null,
5073
+ fwBinaryMap: [],
5704
5074
  });
5075
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5705
5076
  method.postTipMessage = jest.fn();
5706
5077
 
5707
- await method.run();
5078
+ await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5708
5081
 
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
- });
5082
+ getSysResourceBinarySpy.mockRestore();
5083
+ getFirmwareLatestReleaseSpy.mockRestore();
5715
5084
  });
5716
5085
 
5717
- test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5086
+ test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5718
5087
  const method = new FirmwareUpdateV4({
5719
5088
  id: 1,
5720
5089
  payload: {
5721
5090
  method: 'firmwareUpdateV4',
5722
- platform: 'web',
5723
5091
  },
5724
5092
  });
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();
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
+ });
5737
5127
 
5738
- await expect(method.run()).rejects.toMatchObject({
5739
- errorCode: HardwareErrorCode.NetworkError,
5740
- message: 'latest config unavailable',
5741
- });
5128
+ await expect(
5129
+ (method as any).prepareProtocolV2ResourceSources('universal', {
5130
+ deviceType: 'pro2',
5131
+ })
5132
+ ).rejects.toThrow('resourceBundles[].devicePath');
5133
+ expect(openPreparedSource).not.toHaveBeenCalled();
5742
5134
 
5743
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5744
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5135
+ getFirmwareLatestReleaseSpy.mockRestore();
5745
5136
  });
5746
5137
 
5747
- test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
5138
+ test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5748
5139
  const method = new FirmwareUpdateV4({
5749
5140
  id: 1,
5750
5141
  payload: {
5751
5142
  method: 'firmwareUpdateV4',
5752
5143
  platform: 'web',
5753
- coprocessorBinary: new Uint8Array([1]).buffer,
5144
+ targetsToUpdate: ['resource'],
5754
5145
  },
5755
5146
  });
5756
5147
  method.init();
5757
5148
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5758
5149
 
5759
- (method as any).device = stubDevice({
5760
- originalDescriptor: { protocolType: 'V2' },
5761
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5762
- });
5763
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5764
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5765
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5766
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5767
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5768
- bootloaderVersion: '1.0.0',
5769
- bleVersion: '0.0.0',
5770
- firmwareVersion: '1.0.0',
5771
- });
5772
- method.postTipMessage = jest.fn();
5773
-
5774
- await method.run();
5775
-
5776
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5777
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5778
- bootloaderBinary: null,
5779
- fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
5780
- });
5150
+ const order: string[] = [];
5151
+ const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5152
+ const getSysResourceBinarySpy = jest
5153
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5154
+ .mockImplementation(() => {
5155
+ order.push('download');
5156
+ return Promise.resolve({ binary: resourceBinary });
5157
+ });
5158
+ const getFirmwareLatestReleaseSpy = jest
5159
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5160
+ .mockReturnValue({
5161
+ required: false,
5162
+ version: [1, 0, 0],
5163
+ url: '',
5164
+ resourceBundles: [
5165
+ {
5166
+ name: 'images',
5167
+ url: 'https://example.com/images.okpkg',
5168
+ devicePath: ' VOL0:/resource/images/images.okpkg ',
5169
+ },
5170
+ ],
5171
+ fingerprint: '',
5172
+ changelog: {
5173
+ 'zh-CN': '',
5174
+ 'en-US': '',
5175
+ },
5176
+ });
5177
+
5178
+ (method as any).device = stubDevice({
5179
+ originalDescriptor: { protocolType: 'V2' },
5180
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5181
+ });
5182
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5183
+ bootloaderBinary: null,
5184
+ fwBinaryMap: [],
5185
+ });
5186
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
5187
+ order.push('bootloader');
5188
+ return Promise.resolve(true);
5189
+ });
5190
+ const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5191
+ (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5192
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5193
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5194
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5195
+ bootloaderVersion: '1.0.0',
5196
+ bleVersion: '0.0.0',
5197
+ firmwareVersion: '1.0.0',
5198
+ });
5199
+ method.postTipMessage = jest.fn();
5200
+
5201
+ await method.run();
5202
+
5203
+ expect(order).toEqual(['download', 'bootloader']);
5204
+ expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5205
+ bootloaderBinary: null,
5206
+ fwBinaryMap: [],
5207
+ resourceBundles: [
5208
+ {
5209
+ name: 'images',
5210
+ binary: resourceBinary,
5211
+ devicePath: 'vol0:/resource/images/images.okpkg',
5212
+ url: 'https://example.com/images.okpkg',
5213
+ },
5214
+ ],
5215
+ });
5216
+
5217
+ executeProtocolV2UpdateSpy.mockRestore();
5218
+ getSysResourceBinarySpy.mockRestore();
5219
+ getFirmwareLatestReleaseSpy.mockRestore();
5220
+ });
5221
+
5222
+ test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5223
+ const method = new FirmwareUpdateV4({
5224
+ id: 1,
5225
+ payload: {
5226
+ method: 'firmwareUpdateV4',
5227
+ },
5228
+ });
5229
+ method.init();
5230
+ const getSysResourceBinarySpy = jest
5231
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5232
+ .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5233
+
5234
+ await expect(
5235
+ (method as any).materializeProtocolV2ResourceBundles([
5236
+ {
5237
+ name: 'images',
5238
+ binary: new ArrayBuffer(0),
5239
+ devicePath: 'vol0:/resource/images/images.okpkg',
5240
+ url: 'https://example.com/images.okpkg',
5241
+ },
5242
+ ])
5243
+ ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5244
+
5245
+ getSysResourceBinarySpy.mockRestore();
5246
+ });
5247
+
5248
+ test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5249
+ const method = new FirmwareUpdateV4({
5250
+ id: 1,
5251
+ payload: {
5252
+ method: 'firmwareUpdateV4',
5253
+ platform: 'web',
5254
+ targetsToUpdate: ['resource'],
5255
+ },
5256
+ });
5257
+ method.init();
5258
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5259
+
5260
+ const getSysResourceBinarySpy = jest
5261
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5262
+ .mockResolvedValue({ binary: new ArrayBuffer(0) });
5263
+ const getFirmwareLatestReleaseSpy = jest
5264
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5265
+ .mockReturnValue({
5266
+ required: false,
5267
+ version: [1, 0, 0],
5268
+ url: '',
5269
+ resourceBundles: [
5270
+ {
5271
+ name: 'images',
5272
+ url: 'https://example.com/images.okpkg',
5273
+ devicePath: 'vol0:/resource/images/images.okpkg',
5274
+ },
5275
+ ],
5276
+ fingerprint: '',
5277
+ changelog: {
5278
+ 'zh-CN': '',
5279
+ 'en-US': '',
5280
+ },
5281
+ });
5282
+ (method as any).device = stubDevice({
5283
+ originalDescriptor: { protocolType: 'V2' },
5284
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5285
+ });
5286
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5287
+ bootloaderBinary: null,
5288
+ fwBinaryMap: [],
5289
+ });
5290
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5291
+ method.postTipMessage = jest.fn();
5292
+
5293
+ await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5294
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5295
+
5296
+ getSysResourceBinarySpy.mockRestore();
5297
+ getFirmwareLatestReleaseSpy.mockRestore();
5298
+ });
5299
+
5300
+ test('does not download Pro2 firmware components that App did not select', async () => {
5301
+ const method = new FirmwareUpdateV4({
5302
+ id: 1,
5303
+ payload: {
5304
+ method: 'firmwareUpdateV4',
5305
+ platform: 'web',
5306
+ },
5307
+ });
5308
+ method.init();
5309
+
5310
+ const getSysResourceBinarySpy = jest
5311
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5312
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5313
+ const getFirmwareLatestReleaseSpy = jest
5314
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5315
+ .mockReturnValue({
5316
+ required: false,
5317
+ version: [1, 0, 0],
5318
+ url: 'https://example.com/applicationP1.pp.bin',
5319
+ bootloaderVersion: [1, 0, 0],
5320
+ upgradeType: 'payload-package-set',
5321
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5322
+ components: {
5323
+ bootloader: {
5324
+ target: 'BOOTLOADER',
5325
+ url: 'https://example.com/bootloader.pp.bin',
5326
+ },
5327
+ applicationP1: {
5328
+ target: 'APPLICATION_P1',
5329
+ url: 'https://example.com/applicationP1.pp.bin',
5330
+ },
5331
+ applicationP2: {
5332
+ target: 'APPLICATION_P2',
5333
+ url: 'https://example.com/applicationP2.pp.bin',
5334
+ },
5335
+ },
5336
+ fingerprint: '',
5337
+ changelog: {
5338
+ 'zh-CN': '',
5339
+ 'en-US': '',
5340
+ },
5341
+ });
5342
+
5343
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5344
+ deviceType: 'pro2',
5345
+ firmwareVersion: '1.0.0',
5346
+ bootloaderVersion: '1.0.0',
5347
+ capabilities: [],
5348
+ });
5349
+
5350
+ expect(remoteBinaries).toEqual({
5351
+ bootloaderBinary: null,
5352
+ fwBinaryMap: [],
5353
+ installItems: [],
5354
+ });
5355
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5356
+
5357
+ getSysResourceBinarySpy.mockRestore();
5358
+ getFirmwareLatestReleaseSpy.mockRestore();
5359
+ });
5360
+
5361
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5362
+ const method = new FirmwareUpdateV4({
5363
+ id: 1,
5364
+ payload: {
5365
+ method: 'firmwareUpdateV4',
5366
+ platform: 'web',
5367
+ },
5368
+ });
5369
+ method.init();
5370
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5371
+
5372
+ const remoteBinaries = {
5373
+ bootloaderBinary: new Uint8Array([1]).buffer,
5374
+ fwBinaryMap: [
5375
+ {
5376
+ fileName: 'application_p1.bin',
5377
+ binary: new Uint8Array([2]).buffer,
5378
+ targetId: 3,
5379
+ },
5380
+ ],
5381
+ };
5382
+
5383
+ (method as any).device = stubDevice({
5384
+ originalDescriptor: { protocolType: 'V2' },
5385
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5386
+ });
5387
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5388
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5389
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5390
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5391
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5392
+ bootloaderVersion: '1.0.0',
5393
+ bleVersion: '0.0.0',
5394
+ firmwareVersion: '1.0.0',
5395
+ });
5396
+ method.postTipMessage = jest.fn();
5397
+
5398
+ await method.run();
5399
+
5400
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5401
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5402
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5403
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5404
+ });
5405
+ });
5406
+
5407
+ test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
5408
+ const method = new FirmwareUpdateV4({
5409
+ id: 1,
5410
+ payload: {
5411
+ method: 'firmwareUpdateV4',
5412
+ platform: 'web',
5413
+ coprocessorBinary: new Uint8Array([1]).buffer,
5414
+ },
5415
+ });
5416
+ method.init();
5417
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5418
+
5419
+ (method as any).device = stubDevice({
5420
+ originalDescriptor: { protocolType: 'V2' },
5421
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5422
+ });
5423
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5424
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5425
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5426
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5427
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5428
+ bootloaderVersion: '1.0.0',
5429
+ bleVersion: '0.0.0',
5430
+ firmwareVersion: '1.0.0',
5431
+ });
5432
+ method.postTipMessage = jest.fn();
5433
+
5434
+ await method.run();
5435
+
5436
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5437
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5438
+ bootloaderBinary: null,
5439
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
5440
+ });
5781
5441
  });
5782
5442
 
5783
5443
  test('treats manual resource bundles as explicit payload without remote firmware auto-fill', async () => {
@@ -5869,7 +5529,10 @@ describe('Protocol V2 firmware update targets', () => {
5869
5529
 
5870
5530
  method.postTipMessage = jest.fn();
5871
5531
  method.postProgressMessage = jest.fn();
5872
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5532
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5533
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5534
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5535
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5873
5536
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5874
5537
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5875
5538
 
@@ -5885,7 +5548,7 @@ describe('Protocol V2 firmware update targets', () => {
5885
5548
  fwBinaryMap: [],
5886
5549
  });
5887
5550
 
5888
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5551
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5889
5552
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5890
5553
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5891
5554
  });
@@ -5916,12 +5579,19 @@ describe('Protocol V2 firmware update targets', () => {
5916
5579
  });
5917
5580
  method.postProgressMessage = jest.fn();
5918
5581
 
5919
- await (method as any).protocolV2CommonUpdateProcess({
5920
- payload: new Uint8Array(4097).buffer,
5921
- filePath: 'vol1:firmware.bin',
5922
- processedSize: 0,
5923
- totalSize: 4097,
5582
+ const source = await openFirmwareByteSource({
5583
+ binary: new Uint8Array(4097).buffer,
5924
5584
  });
5585
+ try {
5586
+ await (method as any).protocolV2SourceUpdateProcess({
5587
+ source,
5588
+ filePath: 'vol1:firmware.bin',
5589
+ processedSize: 0,
5590
+ totalSize: 4097,
5591
+ });
5592
+ } finally {
5593
+ await source?.close();
5594
+ }
5925
5595
 
5926
5596
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5927
5597
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5978,12 +5648,16 @@ describe('Protocol V2 firmware update targets', () => {
5978
5648
  method.postProgressMessage = jest.fn();
5979
5649
 
5980
5650
  try {
5981
- await (method as any).protocolV2CommonUpdateProcess({
5982
- payload: new Uint8Array(8001).buffer,
5651
+ const source = await openFirmwareByteSource({
5652
+ binary: new Uint8Array(8001).buffer,
5653
+ });
5654
+ await (method as any).protocolV2SourceUpdateProcess({
5655
+ source,
5983
5656
  filePath: 'vol0:/firmware.bin',
5984
5657
  processedSize: 0,
5985
5658
  totalSize: 8001,
5986
5659
  });
5660
+ await source?.close();
5987
5661
  } finally {
5988
5662
  setTimeoutSpy.mockRestore();
5989
5663
  }
@@ -6018,16 +5692,21 @@ describe('Protocol V2 firmware update targets', () => {
6018
5692
  });
6019
5693
  method.postProgressMessage = jest.fn();
6020
5694
  method.postTipMessage = jest.fn();
5695
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
6021
5696
 
5697
+ const source = await openFirmwareByteSource({
5698
+ binary: new Uint8Array(4097).buffer,
5699
+ });
6022
5700
  await expect(
6023
- (method as any).protocolV2WriteWholeFile({
6024
- payload: new Uint8Array(4097).buffer,
5701
+ (method as any).protocolV2SourceUpdateProcess({
5702
+ source,
6025
5703
  filePath: 'vol0:/firmware.bin',
6026
5704
  processedSize: 0,
6027
5705
  totalSize: 4097,
6028
5706
  })
6029
5707
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
6030
- expect(typedCall).toHaveBeenCalledTimes(2);
5708
+ await source?.close();
5709
+ expect(typedCall).toHaveBeenCalledTimes(6);
6031
5710
  });
6032
5711
 
6033
5712
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -6060,21 +5739,24 @@ describe('Protocol V2 firmware update targets', () => {
6060
5739
  });
6061
5740
  method.postProgressMessage = jest.fn();
6062
5741
 
6063
- await (method as any).protocolV2CommonUpdateProcess({
6064
- payload: new Uint8Array(1801).buffer,
6065
- filePath: 'vol1:ble-firmware.bin',
6066
- processedSize: 0,
6067
- totalSize: 1801,
5742
+ const source = await openFirmwareByteSource({
5743
+ binary: new Uint8Array(1801).buffer,
6068
5744
  });
5745
+ try {
5746
+ await (method as any).protocolV2SourceUpdateProcess({
5747
+ source,
5748
+ filePath: 'vol1:ble-firmware.bin',
5749
+ processedSize: 0,
5750
+ totalSize: 1801,
5751
+ });
5752
+ } finally {
5753
+ await source?.close();
5754
+ }
6069
5755
 
6070
5756
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
6071
5757
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
6072
5758
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
6073
5759
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
6074
- expect(typedCall.mock.calls.map(call => call[3])).toEqual([
6075
- { writeWithResponse: true },
6076
- { writeWithResponse: true },
6077
- ]);
6078
5760
  });
6079
5761
 
6080
5762
  test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
@@ -6186,9 +5868,9 @@ describe('Protocol V2 firmware update method', () => {
6186
5868
  const typedCall = jest
6187
5869
  .fn()
6188
5870
  .mockRejectedValue(
6189
- new DOMException(
6190
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
6191
- 'NotFoundError'
5871
+ Object.assign(
5872
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
+ { name: 'NotFoundError' }
6192
5874
  )
6193
5875
  );
6194
5876
 
@@ -6353,63 +6035,20 @@ describe('Protocol V2 firmware update method', () => {
6353
6035
  });
6354
6036
 
6355
6037
  describe('Protocol V2 firmware reconnect identity', () => {
6356
- const createResourceFilesystemTypedCall = (
6357
- stable: Array<{ type: string; size: number; headerHash: string }>,
6358
- missingTypes: string[] = []
6359
- ) => {
6360
- const missing = new Set(missingTypes);
6361
- const resourceByPath = new Map(
6362
- stable.map(resource => [
6363
- PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6364
- resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6365
- ],
6366
- resource,
6367
- ])
6368
- );
6369
- return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6370
- if (requestType === 'ResourceInventoryGet') {
6371
- throw new Error('ResourceInventoryGet is unavailable on released firmware');
6372
- }
6373
- if (requestType === 'FilesystemPathInfoQuery') {
6374
- const resource = resourceByPath.get(payload.path);
6375
- const exists = Boolean(resource && !missing.has(resource.type));
6376
- return {
6377
- message: {
6378
- exist: exists,
6379
- directory: false,
6380
- size: exists ? resource?.size : 0,
6381
- },
6382
- };
6383
- }
6384
- if (requestType === 'FilesystemFileRead') {
6385
- const resource = resourceByPath.get(payload.file.path);
6386
- if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6387
- const header = new Uint8Array(0x52a0);
6388
- const view = new DataView(header.buffer);
6389
- 'OKPP'.split('').forEach((char, index) => {
6390
- header[index] = char.charCodeAt(0);
6391
- });
6392
- 'RESC'.split('').forEach((char, index) => {
6393
- header[0x08 + index] = char.charCodeAt(0);
6394
- });
6395
- view.setUint32(0x0c, header.byteLength, true);
6396
- for (let index = 0; index < resource.headerHash.length / 2; index++) {
6397
- header[0x240 + index] = Number.parseInt(
6398
- resource.headerHash.slice(index * 2, index * 2 + 2),
6399
- 16
6400
- );
6401
- }
6402
- const offset = Number(payload.file.offset);
6403
- const chunkLength = Number(payload.chunk_len);
6404
- return {
6405
- message: {
6406
- data: header.slice(offset, offset + chunkLength),
6407
- },
6408
- };
6409
- }
6410
- throw new Error(`Unexpected request: ${requestType}`);
6038
+ test('keeps the host plan identity as the V4 reconnect anchor', () => {
6039
+ const method = new FirmwareUpdateV4({
6040
+ id: 1,
6041
+ payload: {
6042
+ method: 'firmwareUpdateV4',
6043
+ platform: 'native',
6044
+ expectedDeviceId: 'persisted-device-id',
6045
+ },
6411
6046
  });
6412
- };
6047
+
6048
+ method.init();
6049
+
6050
+ expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6051
+ });
6413
6052
 
6414
6053
  test('rejects a different physical serial before firmware transfer resumes', () => {
6415
6054
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
@@ -6420,10 +6059,16 @@ describe('Protocol V2 firmware reconnect identity', () => {
6420
6059
  ).not.toThrow();
6421
6060
  });
6422
6061
 
6423
- test('allows reconnect when either physical serial is unavailable', () => {
6424
- expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).not.toThrow();
6425
- expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).not.toThrow();
6426
- expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).not.toThrow();
6062
+ test('fails closed when the physical serial is unavailable', () => {
6063
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
6064
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6065
+ );
6066
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).toThrow(
6067
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6068
+ );
6069
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).toThrow(
6070
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6071
+ );
6427
6072
  });
6428
6073
 
6429
6074
  test('captures physical identity from active DeviceInfo instead of wallet deviceId', async () => {
@@ -6456,7 +6101,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6456
6101
  );
6457
6102
  });
6458
6103
 
6459
- test('allows firmware update startup when DeviceInfo has no physical serial', async () => {
6104
+ test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
6460
6105
  const method = new FirmwareUpdateV4({
6461
6106
  id: 1,
6462
6107
  payload: { method: 'firmwareUpdateV4' },
@@ -6469,209 +6114,39 @@ describe('Protocol V2 firmware reconnect identity', () => {
6469
6114
  getCommands: () => ({ typedCall }),
6470
6115
  });
6471
6116
 
6472
- await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
6473
- expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
6474
- });
6475
-
6476
- test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6477
- const method = new FirmwareUpdateV4({
6478
- id: 1,
6479
- payload: {
6480
- method: 'firmwareUpdateV4',
6481
- platform: 'web',
6482
- targetsToUpdate: ['resource'],
6483
- },
6484
- });
6485
- method.init();
6486
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6487
- (type, index) => ({
6488
- type,
6489
- url: `https://example.com/${type}.okpkg`,
6490
- size: 0x52a0 + index + 1,
6491
- fileHash: 'a'.repeat(64),
6492
- headerHash: index.toString(16).padStart(128, '0'),
6493
- })
6494
- );
6495
- const typedCall = createResourceFilesystemTypedCall(stable);
6496
- (method as any).device = stubDevice({
6497
- getCommands: () => ({ typedCall }),
6498
- });
6499
- const resourcesSpy = jest
6500
- .spyOn(DataManager, 'getProtocolV2Resources')
6501
- .mockReturnValue(stable as any);
6502
- (method as any).downloadProtocolV2Resource = jest.fn();
6503
-
6504
- await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6505
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6506
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6507
- expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6508
- resourcesSpy.mockRestore();
6509
- });
6510
-
6511
- test('downloads only changed resources from loader inventory', async () => {
6512
- const method = new FirmwareUpdateV4({
6513
- id: 1,
6514
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6117
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
6118
+ errorCode: HardwareErrorCode.DeviceNotFound,
6515
6119
  });
6516
- method.init();
6517
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6518
- (type, index) => ({
6519
- type,
6520
- url: `https://example.com/${type}.okpkg`,
6521
- size: 0x52a0 + index + 1,
6522
- fileHash: 'a'.repeat(64),
6523
- headerHash: index.toString(16).padStart(128, '0'),
6524
- })
6525
- );
6526
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6527
- (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6528
- const resourcesSpy = jest
6529
- .spyOn(DataManager, 'getProtocolV2Resources')
6530
- .mockReturnValue(stable as any);
6531
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6532
- Promise.resolve({
6533
- name: `${resource.type}.okpkg`,
6534
- binary: new ArrayBuffer(resource.size),
6535
- devicePath: `vol0:/${resource.type}.okpkg`,
6536
- })
6537
- );
6538
-
6539
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6540
-
6541
- expect(bundles).toHaveLength(1);
6542
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6543
- resourcesSpy.mockRestore();
6544
- });
6545
-
6546
- test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6547
- const method = new FirmwareUpdateV4({
6548
- id: 1,
6549
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6550
- });
6551
- method.init();
6552
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6553
- (type, index) => ({
6554
- type,
6555
- url: `https://example.com/${type}.okpkg`,
6556
- size: 0x52a0 + index + 1,
6557
- fileHash: 'a'.repeat(64),
6558
- headerHash: index.toString(16).padStart(128, '0'),
6559
- })
6560
- );
6561
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6562
- (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6563
- const resourcesSpy = jest
6564
- .spyOn(DataManager, 'getProtocolV2Resources')
6565
- .mockReturnValue(stable as any);
6566
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6567
- Promise.resolve({
6568
- name: `${resource.type}.okpkg`,
6569
- binary: new ArrayBuffer(resource.size),
6570
- devicePath: `vol0:/${resource.type}.okpkg`,
6571
- })
6572
- );
6573
-
6574
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6575
-
6576
- expect(bundles).toHaveLength(1);
6577
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6578
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6579
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6580
- resourcesSpy.mockRestore();
6581
6120
  });
6582
6121
 
6583
- const buildBootResourcesHeader = ({
6584
- type = 'CRAT',
6585
- payloadHash = 'ab'.repeat(64),
6586
- headerHash = 'cd'.repeat(64),
6587
- }: {
6588
- type?: string;
6589
- payloadHash?: string;
6590
- headerHash?: string;
6591
- } = {}) => {
6592
- const header = new Uint8Array(0x52a0);
6593
- const view = new DataView(header.buffer);
6594
- 'OKPP'.split('').forEach((char, index) => {
6595
- header[index] = char.charCodeAt(0);
6596
- });
6597
- type.split('').forEach((char, index) => {
6598
- header[0x08 + index] = char.charCodeAt(0);
6599
- });
6600
- view.setUint32(0x0c, header.byteLength, true);
6601
- const writeHex = (offset: number, hex: string) => {
6602
- for (let index = 0; index < hex.length / 2; index++) {
6603
- header[offset + index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
6604
- }
6605
- };
6606
- writeHex(0x200, payloadHash);
6607
- writeHex(0x240, headerHash);
6608
- return header;
6609
- };
6610
-
6611
- test('does not resolve boot resources unless the optional target is selected', async () => {
6612
- const method = new FirmwareUpdateV4({
6613
- id: 1,
6614
- payload: { method: 'firmwareUpdateV4', platform: 'web' },
6615
- });
6616
- method.init();
6617
- const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6618
- const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6619
-
6620
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6621
-
6622
- expect(configSpy).not.toHaveBeenCalled();
6623
- expect(downloadSpy).not.toHaveBeenCalled();
6624
- });
6625
-
6626
- test('downloads and maps the selected boot resources CRATE target', async () => {
6627
- const payloadHash = 'ab'.repeat(64);
6628
- const headerHash = 'cd'.repeat(64);
6629
- const bytes = buildBootResourcesHeader({ payloadHash, headerHash });
6630
- const binary = bytes.buffer as ArrayBuffer;
6631
- const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6632
- const resource = {
6633
- required: false as const,
6634
- target: 'CRATE' as const,
6635
- url: 'https://example.com/boot-resources.crate.okpkg',
6636
- size: bytes.byteLength,
6637
- fileHash,
6638
- payloadHash,
6639
- headerHash,
6640
- };
6122
+ test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
6641
6123
  const method = new FirmwareUpdateV4({
6642
6124
  id: 1,
6643
6125
  payload: {
6644
6126
  method: 'firmwareUpdateV4',
6645
- platform: 'web',
6646
- targetsToUpdate: ['boot_resources'],
6127
+ platform: 'native',
6128
+ chunkSize: 1800,
6647
6129
  },
6648
6130
  });
6649
6131
  method.init();
6650
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6651
- jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6652
-
6653
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual({
6654
- fileName: 'boot_resources.crate.okpkg',
6655
- binary,
6656
- targetId: 1,
6657
- kind: 'boot_resources',
6132
+ const readChunkLengths: number[] = [];
6133
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6134
+ if (requestType === 'FilesystemPathInfoQuery') {
6135
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
6136
+ }
6137
+ if (requestType === 'FilesystemFileRead') {
6138
+ readChunkLengths.push(request.chunk_len);
6139
+ return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
6140
+ }
6141
+ throw new Error(`Unexpected request: ${requestType}`);
6658
6142
  });
6659
- });
6660
-
6661
- test('rejects a non-CRATE manual boot resources package', async () => {
6662
- const method = new FirmwareUpdateV4({
6663
- id: 1,
6664
- payload: {
6665
- method: 'firmwareUpdateV4',
6666
- platform: 'web',
6667
- bootResourcesBinary: buildBootResourcesHeader({ type: 'RESC' }).buffer,
6668
- },
6143
+ (method as any).device = stubDevice({
6144
+ getCommands: () => ({ typedCall }),
6669
6145
  });
6670
- method.init();
6671
6146
 
6672
- await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
6673
- 'Invalid Pro2 boot resources CRATE header'
6674
- );
6147
+ await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
+ expect(readChunkLengths.length).toBeGreaterThan(1);
6149
+ expect(Math.max(...readChunkLengths)).toBe(900);
6675
6150
  });
6676
6151
  });
6677
6152
 
@@ -6798,245 +6273,326 @@ describe('Protocol V2 protected method execution', () => {
6798
6273
  });
6799
6274
  });
6800
6275
 
6801
- test.each([
6802
- ['deviceInfoGet', DeviceInfoGet],
6803
- ['deviceStatusGet', DeviceStatusGet],
6804
- ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6805
- const method = new MethodClass({
6806
- id: 1,
6807
- payload: { method: _name },
6808
- } as any);
6809
- method.init();
6276
+ test.each([
6277
+ ['deviceInfoGet', DeviceInfoGet],
6278
+ ['deviceStatusGet', DeviceStatusGet],
6279
+ ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6280
+ const method = new MethodClass({
6281
+ id: 1,
6282
+ payload: { method: _name },
6283
+ } as any);
6284
+ method.init();
6285
+
6286
+ expect(method.unlockPolicy).toBe('none');
6287
+ });
6288
+
6289
+ test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6290
+ const calls: string[] = [];
6291
+ const method = {
6292
+ name: 'testBusinessMethod',
6293
+ unlockPolicy: 'retry-on-locked',
6294
+ protocolV2UiInteraction: { reason: 'settings-page' },
6295
+ run: jest
6296
+ .fn()
6297
+ .mockImplementationOnce(() => {
6298
+ calls.push('run-1');
6299
+ return Promise.reject(deviceLockedError());
6300
+ })
6301
+ .mockImplementationOnce(() => {
6302
+ calls.push('run-2');
6303
+ return Promise.resolve({ message: 'ok' });
6304
+ }),
6305
+ };
6306
+ const device = {
6307
+ isProtocolV2: () => true,
6308
+ unlockDevice: jest.fn(() => {
6309
+ calls.push('unlock');
6310
+ return Promise.resolve();
6311
+ }),
6312
+ };
6313
+ const uiCoordinator = {
6314
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6315
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6317
+ };
6318
+
6319
+ await expect(
6320
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6321
+ ).resolves.toEqual({ message: 'ok' });
6322
+ expect(calls).toEqual([
6323
+ 'method-prompt',
6324
+ 'run-1',
6325
+ 'unlock-prompt',
6326
+ 'unlock',
6327
+ 'method-prompt',
6328
+ 'run-2',
6329
+ ]);
6330
+ });
6331
+
6332
+ test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6333
+ const calls: string[] = [];
6334
+ const method = {
6335
+ name: 'evmSignMessage',
6336
+ payload: { passphraseState: 'hidden-state' },
6337
+ useDevicePassphraseState: true,
6338
+ unlockPolicy: 'retry-on-locked',
6339
+ run: jest
6340
+ .fn()
6341
+ .mockImplementationOnce(() => {
6342
+ calls.push('run-1');
6343
+ return Promise.reject(deviceLockedError());
6344
+ })
6345
+ .mockImplementationOnce(() => {
6346
+ calls.push('run-2');
6347
+ return Promise.resolve({ message: 'ok' });
6348
+ }),
6349
+ };
6350
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6351
+ if (requestType === 'ProtocolInfoRequest') {
6352
+ calls.push('negotiate-session');
6353
+ return Promise.resolve({ message: { version: 2 } });
6354
+ }
6355
+ if (requestType === 'DeviceSessionGet') {
6356
+ calls.push('resume-hidden-session');
6357
+ expect(request).toEqual({ session_id: 'hidden-session' });
6358
+ return Promise.resolve({
6359
+ message: {
6360
+ session_id: 'hidden-session',
6361
+ btc_test_address: 'hidden-state',
6362
+ },
6363
+ });
6364
+ }
6365
+ throw new Error(`Unexpected request: ${requestType}`);
6366
+ });
6367
+ const device = {
6368
+ features: { unlocked: true, passphraseProtection: true },
6369
+ passphraseState: 'hidden-state',
6370
+ commands: { typedCall },
6371
+ isProtocolV2: () => true,
6372
+ unlockDevice: jest.fn(() => {
6373
+ calls.push('unlock');
6374
+ return Promise.resolve();
6375
+ }),
6376
+ getCurrentPassphraseProtection: () => true,
6377
+ getInternalState: () => 'hidden-session',
6378
+ clearInternalState: jest.fn(),
6379
+ getCurrentDeviceId: () => 'wallet-device-id',
6380
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6381
+ };
6382
+
6383
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6384
+ message: 'ok',
6385
+ });
6386
+ expect(calls).toEqual([
6387
+ 'run-1',
6388
+ 'unlock',
6389
+ 'negotiate-session',
6390
+ 'resume-hidden-session',
6391
+ 'validate-hidden-session',
6392
+ 'run-2',
6393
+ ]);
6394
+ });
6395
+
6396
+ test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6397
+ const initialError = deviceLockedError();
6398
+ const restoreError = new Error('Invalid wallet session');
6399
+ const method = {
6400
+ name: 'evmSignMessage',
6401
+ payload: { passphraseState: 'hidden-state' },
6402
+ useDevicePassphraseState: true,
6403
+ unlockPolicy: 'retry-on-locked',
6404
+ run: jest.fn().mockRejectedValueOnce(initialError),
6405
+ };
6406
+ const typedCall = jest.fn((requestType: string) => {
6407
+ if (requestType === 'ProtocolInfoRequest') {
6408
+ return Promise.resolve({ message: { version: 2 } });
6409
+ }
6410
+ if (requestType === 'DeviceSessionGet') {
6411
+ return Promise.reject(restoreError);
6412
+ }
6413
+ throw new Error(`Unexpected request: ${requestType}`);
6414
+ });
6415
+ const device = {
6416
+ features: { unlocked: true, passphraseProtection: true },
6417
+ passphraseState: 'hidden-state',
6418
+ commands: { typedCall },
6419
+ isProtocolV2: () => true,
6420
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6421
+ getCurrentPassphraseProtection: () => true,
6422
+ getInternalState: () => 'hidden-session',
6423
+ clearInternalState: jest.fn(),
6424
+ getCurrentDeviceId: () => 'wallet-device-id',
6425
+ updateInternalState: jest.fn(),
6426
+ };
6810
6427
 
6811
- expect(method.unlockPolicy).toBe('none');
6428
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6429
+ expect(method.run).toHaveBeenCalledTimes(1);
6430
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6431
+ expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6432
+ expect(device.updateInternalState).not.toHaveBeenCalled();
6812
6433
  });
6813
6434
 
6814
- test('checks fresh status for a wallet business method without a name allowlist', async () => {
6435
+ test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6815
6436
  const calls: string[] = [];
6816
- const features = { unlocked: true };
6817
6437
  const method = {
6818
- name: 'confluxSignMessageCIP23',
6819
- unlockPolicy: 'none',
6438
+ name: 'evmSignMessage',
6439
+ payload: { passphraseState: 'hidden-state' },
6820
6440
  useDevicePassphraseState: true,
6821
- protocolV2UiInteraction: { reason: 'address-confirmation' },
6441
+ unlockPolicy: 'retry-on-locked',
6822
6442
  run: jest.fn(() => {
6823
6443
  calls.push('run');
6824
- return Promise.resolve({ address: '0x1' });
6444
+ return Promise.resolve({ message: 'ok' });
6825
6445
  }),
6826
6446
  };
6447
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6448
+ if (requestType === 'ProtocolInfoRequest') {
6449
+ calls.push('negotiate-session');
6450
+ return Promise.resolve({ message: { version: 2 } });
6451
+ }
6452
+ if (requestType === 'DeviceSessionGet') {
6453
+ calls.push('resume-hidden-session');
6454
+ expect(request).toEqual({ session_id: 'hidden-session' });
6455
+ return Promise.resolve({
6456
+ message: {
6457
+ session_id: 'hidden-session',
6458
+ btc_test_address: 'hidden-state',
6459
+ },
6460
+ });
6461
+ }
6462
+ throw new Error(`Unexpected request: ${requestType}`);
6463
+ });
6464
+ const features = {
6465
+ unlocked: false,
6466
+ unlockedAttachPin: true,
6467
+ passphraseProtection: true,
6468
+ };
6827
6469
  const device = {
6828
6470
  features,
6829
- commands: {
6830
- typedCall: jest.fn(() => {
6831
- calls.push('status');
6832
- return Promise.resolve({ message: { unlocked: true } });
6833
- }),
6834
- },
6471
+ passphraseState: 'hidden-state',
6472
+ commands: { typedCall },
6835
6473
  isProtocolV2: () => true,
6836
- isBootloader: () => false,
6837
- isRomloader: () => false,
6838
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6839
- features.unlocked = status.unlocked === true;
6840
- return features;
6474
+ unlockDevice: jest.fn(() => {
6475
+ calls.push('unlock-main');
6476
+ features.unlocked = true;
6477
+ features.unlockedAttachPin = false;
6478
+ return Promise.resolve();
6841
6479
  }),
6842
- unlockDevice: jest.fn(),
6843
- };
6844
- const uiCoordinator = {
6845
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6846
- enterUnlockInteraction: jest.fn(),
6480
+ getCurrentPassphraseProtection: () => true,
6481
+ getInternalState: () => 'hidden-session',
6482
+ clearInternalState: jest.fn(),
6483
+ getCurrentDeviceId: () => 'wallet-device-id',
6484
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6847
6485
  };
6848
6486
 
6849
- await expect(
6850
- runMethodWithUnlockPolicy(method as any, device as any, {
6851
- uiCoordinator: uiCoordinator as any,
6852
- })
6853
- ).resolves.toEqual({ address: '0x1' });
6854
-
6855
- expect(calls).toEqual(['status', 'method-prompt', 'run']);
6856
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6857
- expect(device.unlockDevice).not.toHaveBeenCalled();
6858
- expect(method.run).toHaveBeenCalledTimes(1);
6487
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
+ message: 'ok',
6489
+ });
6490
+ expect(device.unlockDevice).toHaveBeenCalledWith();
6491
+ expect(calls).toEqual([
6492
+ 'unlock-main',
6493
+ 'negotiate-session',
6494
+ 'resume-hidden-session',
6495
+ 'validate-hidden-session',
6496
+ 'run',
6497
+ ]);
6859
6498
  });
6860
6499
 
6861
- test('validates the fresh device identity before starting unlock', async () => {
6862
- const calls: string[] = [];
6863
- const identityError = new Error('Unexpected device');
6500
+ test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6864
6501
  const method = {
6865
- name: 'evmSignMessage',
6866
- unlockPolicy: 'none',
6502
+ name: 'evmGetAddress',
6503
+ payload: { useEmptyPassphrase: true },
6867
6504
  useDevicePassphraseState: true,
6868
- run: jest.fn(),
6505
+ unlockPolicy: 'retry-on-locked',
6506
+ run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6869
6507
  };
6870
- const features = { unlocked: false };
6871
6508
  const device = {
6872
- features,
6873
- commands: {
6874
- typedCall: jest.fn(() => {
6875
- calls.push('status');
6876
- return Promise.resolve({ message: { unlocked: false } });
6877
- }),
6878
- },
6509
+ features: { unlocked: false },
6510
+ passphraseState: 'stale-hidden-state',
6879
6511
  isProtocolV2: () => true,
6880
- isBootloader: () => false,
6881
- isRomloader: () => false,
6882
- updateProtocolV2Status: jest.fn(() => features),
6883
- unlockDevice: jest.fn(),
6512
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6884
6513
  };
6885
6514
 
6886
- await expect(
6887
- runMethodWithUnlockPolicy(method as any, device as any, {
6888
- afterStatusBeforeUnlock: () => {
6889
- calls.push('identity');
6890
- throw identityError;
6891
- },
6892
- })
6893
- ).rejects.toBe(identityError);
6894
-
6895
- expect(calls).toEqual(['status', 'identity']);
6896
- expect(device.unlockDevice).not.toHaveBeenCalled();
6897
- expect(method.run).not.toHaveBeenCalled();
6515
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
+ address: 'standard-wallet-address',
6517
+ });
6518
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
+ expect(method.run).toHaveBeenCalledTimes(1);
6898
6520
  });
6899
6521
 
6900
- test('unlocks before business and never replays the callback', async () => {
6522
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6901
6523
  const calls: string[] = [];
6902
- const features = { unlocked: true };
6903
6524
  const method = {
6904
- name: 'evmSignMessage',
6525
+ name: 'deviceSettingsPageShow',
6905
6526
  unlockPolicy: 'unlock-before-run',
6906
- protocolV2UiInteraction: { reason: 'signing-confirmation' },
6527
+ protocolV2UiInteraction: { reason: 'settings-page' },
6907
6528
  run: jest.fn(() => {
6908
6529
  calls.push('run');
6909
- return Promise.resolve({ signature: 'signed' });
6530
+ return Promise.resolve({ message: 'ok' });
6910
6531
  }),
6911
6532
  };
6533
+ const features = { unlocked: true };
6912
6534
  const device = {
6913
6535
  features,
6914
6536
  commands: {
6915
6537
  typedCall: jest.fn(() => {
6916
- calls.push('status');
6538
+ calls.push('refresh-status');
6917
6539
  return Promise.resolve({ message: { unlocked: false } });
6918
6540
  }),
6919
6541
  },
6920
6542
  isProtocolV2: () => true,
6921
- isBootloader: () => false,
6922
- isRomloader: () => false,
6923
6543
  updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6924
- features.unlocked = status.unlocked === true;
6544
+ features.unlocked = status.unlocked ?? features.unlocked;
6925
6545
  return features;
6926
6546
  }),
6927
6547
  unlockDevice: jest.fn(() => {
6928
6548
  calls.push('unlock');
6929
6549
  features.unlocked = true;
6930
- return Promise.resolve(features);
6550
+ return Promise.resolve();
6931
6551
  }),
6932
6552
  };
6933
6553
  const uiCoordinator = {
6934
6554
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6935
- enterUnlockInteraction: jest.fn(() => {
6936
- calls.push('unlock-prompt');
6937
- return undefined;
6938
- }),
6555
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6556
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6939
6557
  };
6940
6558
 
6941
6559
  await expect(
6942
- runMethodWithUnlockPolicy(method as any, device as any, {
6943
- uiCoordinator: uiCoordinator as any,
6944
- prepare: () => {
6945
- calls.push('wallet-session');
6946
- return Promise.resolve();
6947
- },
6948
- })
6949
- ).resolves.toEqual({ signature: 'signed' });
6950
-
6951
- expect(calls).toEqual([
6952
- 'status',
6953
- 'unlock-prompt',
6954
- 'unlock',
6955
- 'wallet-session',
6956
- 'method-prompt',
6957
- 'run',
6958
- ]);
6959
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6560
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6561
+ ).resolves.toEqual({ message: 'ok' });
6562
+ expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6563
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6960
6564
  expect(method.run).toHaveBeenCalledTimes(1);
6565
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6961
6567
  });
6962
6568
 
6963
- test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6964
- const method = {
6965
- name: 'evmGetAddress',
6966
- unlockPolicy: 'unlock-before-run',
6967
- run: jest.fn(),
6968
- };
6969
- const device = {
6970
- features: { unlocked: undefined },
6971
- commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6972
- isProtocolV2: () => true,
6973
- isBootloader: () => false,
6974
- isRomloader: () => false,
6975
- updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6976
- unlockDevice: jest.fn(),
6977
- };
6978
-
6979
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6980
- errorCode: HardwareErrorCode.RuntimeError,
6981
- });
6982
- expect(device.unlockDevice).not.toHaveBeenCalled();
6983
- expect(method.run).not.toHaveBeenCalled();
6984
- });
6985
-
6986
- test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6987
- const error = deviceLockedError();
6569
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
+ const features = { unlocked: false };
6988
6571
  const method = {
6989
- name: 'evmSignMessage',
6572
+ name: 'deviceSettings',
6990
6573
  unlockPolicy: 'unlock-before-run',
6991
- run: jest.fn().mockRejectedValue(error),
6574
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6992
6575
  };
6993
- const features = { unlocked: true };
6994
6576
  const device = {
6995
6577
  features,
6996
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6578
+ commands: {
6579
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
+ },
6997
6581
  isProtocolV2: () => true,
6998
- isBootloader: () => false,
6999
- isRomloader: () => false,
7000
- updateProtocolV2Status: jest.fn(() => features),
6582
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
+ features.unlocked = status.unlocked ?? features.unlocked;
6584
+ return features;
6585
+ }),
7001
6586
  unlockDevice: jest.fn(),
7002
6587
  };
7003
6588
 
7004
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6589
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6590
+ message: 'ok',
6591
+ });
7005
6592
  expect(device.unlockDevice).not.toHaveBeenCalled();
7006
6593
  expect(method.run).toHaveBeenCalledTimes(1);
7007
6594
  });
7008
6595
 
7009
- test('reuses one lightweight preflight context across nested protected methods', async () => {
7010
- const features = { unlocked: true };
7011
- const context = createProtocolV2UnlockContext();
7012
- const firstMethod = {
7013
- name: 'allNetworkGetAddress',
7014
- unlockPolicy: 'unlock-before-run',
7015
- run: jest.fn().mockResolvedValue({ root: true }),
7016
- };
7017
- const nestedMethod = {
7018
- name: 'evmGetAddress',
7019
- unlockPolicy: 'unlock-before-run',
7020
- run: jest.fn().mockResolvedValue({ address: '0x1' }),
7021
- };
7022
- const device = {
7023
- features,
7024
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
7025
- isProtocolV2: () => true,
7026
- isBootloader: () => false,
7027
- isRomloader: () => false,
7028
- updateProtocolV2Status: jest.fn(() => features),
7029
- unlockDevice: jest.fn(),
7030
- };
7031
-
7032
- await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
7033
- await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
7034
-
7035
- expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
7036
- expect(firstMethod.run).toHaveBeenCalledTimes(1);
7037
- expect(nestedMethod.run).toHaveBeenCalledTimes(1);
7038
- });
7039
-
7040
6596
  test.each(['bootloader', 'romloader'] as const)(
7041
6597
  'skips DeviceStatus and unlock in Protocol V2 %s mode',
7042
6598
  async mode => {
@@ -7054,7 +6610,7 @@ describe('Protocol V2 protected method execution', () => {
7054
6610
  unlockDevice: jest.fn(),
7055
6611
  };
7056
6612
 
7057
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6613
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
7058
6614
  message: 'updating',
7059
6615
  });
7060
6616
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -7063,30 +6619,157 @@ describe('Protocol V2 protected method execution', () => {
7063
6619
  }
7064
6620
  );
7065
6621
 
7066
- test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6622
+ test('infers a signing interaction before running a Protocol V2 business method', async () => {
6623
+ const method = {
6624
+ name: 'evmSignMessage',
6625
+ params: { message: 'not-exposed-in-event' },
6626
+ payload: {},
6627
+ unlockPolicy: 'retry-on-locked',
6628
+ run: jest.fn().mockResolvedValue({ signature: 'test' }),
6629
+ };
6630
+ const device = {
6631
+ isProtocolV2: () => true,
6632
+ unlockDevice: jest.fn(),
6633
+ };
6634
+ const uiCoordinator = {
6635
+ enterMethodInteraction: jest.fn(),
6636
+ enterUnlockInteraction: jest.fn(),
6637
+ resumeMethodInteraction: jest.fn(),
6638
+ };
6639
+
6640
+ await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6641
+
6642
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6643
+ request: 'button',
6644
+ source: 'method-lifecycle',
6645
+ reason: 'signing-confirmation',
6646
+ completion: 'operation-completed',
6647
+ deviceOnly: true,
6648
+ operation: 'evmSignMessage',
6649
+ });
6650
+ });
6651
+
6652
+ test('does not unlock a Protocol V1 device or a method without the policy', async () => {
7067
6653
  for (const [isProtocolV2, unlockPolicy] of [
7068
- [false, 'unlock-before-run'],
6654
+ [false, 'retry-on-locked'],
7069
6655
  [true, 'none'],
7070
6656
  ] as const) {
6657
+ const error = deviceLockedError();
7071
6658
  const method = {
7072
6659
  unlockPolicy,
7073
- useDevicePassphraseState: false,
7074
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6660
+ run: jest.fn().mockRejectedValue(error),
7075
6661
  };
7076
6662
  const device = {
7077
- commands: { typedCall: jest.fn() },
7078
6663
  isProtocolV2: () => isProtocolV2,
7079
6664
  unlockDevice: jest.fn(),
7080
6665
  };
7081
6666
 
7082
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
7083
- message: 'ok',
7084
- });
7085
- expect(device.commands.typedCall).not.toHaveBeenCalled();
6667
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
7086
6668
  expect(device.unlockDevice).not.toHaveBeenCalled();
7087
6669
  expect(method.run).toHaveBeenCalledTimes(1);
7088
6670
  }
7089
6671
  });
6672
+
6673
+ test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6674
+ const method = {
6675
+ name: 'uploadPortfolio',
6676
+ unlockPolicy: 'none',
6677
+ protocolV2UiMode: 'none',
6678
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6679
+ };
6680
+ const device = {
6681
+ features: { unlocked: false },
6682
+ isProtocolV2: () => true,
6683
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6684
+ };
6685
+ const uiCoordinator = {
6686
+ enterMethodInteraction: jest.fn(),
6687
+ enterUnlockInteraction: jest.fn(),
6688
+ resumeMethodInteraction: jest.fn(),
6689
+ };
6690
+
6691
+ await expect(
6692
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6693
+ ).resolves.toEqual({ message: 'ok' });
6694
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6695
+ expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6696
+ expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6697
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6698
+ });
6699
+
6700
+ test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6701
+ const error = deviceLockedError();
6702
+ const method = {
6703
+ name: 'uploadPortfolio',
6704
+ unlockPolicy: 'none',
6705
+ run: jest.fn().mockRejectedValue(error),
6706
+ };
6707
+ const device = {
6708
+ features: { unlocked: true },
6709
+ isProtocolV2: () => true,
6710
+ unlockDevice: jest.fn(),
6711
+ };
6712
+
6713
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6714
+ expect(method.run).toHaveBeenCalledTimes(1);
6715
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6716
+ });
6717
+
6718
+ test('does not retry when unlock fails or when the retry is still locked', async () => {
6719
+ const initialError = deviceLockedError();
6720
+ const unlockError = new Error('PIN cancelled');
6721
+ const unlockFailMethod = {
6722
+ unlockPolicy: 'retry-on-locked',
6723
+ run: jest.fn().mockRejectedValue(initialError),
6724
+ };
6725
+ const unlockFailDevice = {
6726
+ isProtocolV2: () => true,
6727
+ unlockDevice: jest.fn().mockRejectedValue(unlockError),
6728
+ };
6729
+ const unlockFailCoordinator = {
6730
+ enterMethodInteraction: jest.fn(),
6731
+ enterUnlockInteraction: jest.fn(),
6732
+ resumeMethodInteraction: jest.fn(),
6733
+ };
6734
+
6735
+ await expect(
6736
+ runMethodWithUnlockRetry(
6737
+ unlockFailMethod as any,
6738
+ unlockFailDevice as any,
6739
+ unlockFailCoordinator as any
6740
+ )
6741
+ ).rejects.toBe(unlockError);
6742
+ expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6743
+ expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6744
+ expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6745
+
6746
+ const retryError = deviceLockedError();
6747
+ const retryFailMethod = {
6748
+ unlockPolicy: 'retry-on-locked',
6749
+ run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6750
+ };
6751
+ const retryFailDevice = {
6752
+ isProtocolV2: () => true,
6753
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6754
+ };
6755
+ const retryFailCoordinator = {
6756
+ enterMethodInteraction: jest.fn(),
6757
+ enterUnlockInteraction: jest.fn(),
6758
+ resumeMethodInteraction: jest.fn(),
6759
+ };
6760
+
6761
+ await expect(
6762
+ runMethodWithUnlockRetry(
6763
+ retryFailMethod as any,
6764
+ retryFailDevice as any,
6765
+ retryFailCoordinator as any
6766
+ )
6767
+ ).rejects.toBe(retryError);
6768
+ expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6769
+ expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6770
+ expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6771
+ expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6772
+ });
7090
6773
  });
7091
6774
 
7092
6775
  describe('Protocol V2 current low-level methods', () => {
@@ -7218,9 +6901,7 @@ describe('Protocol V2 current low-level methods', () => {
7218
6901
 
7219
6902
  await method.run();
7220
6903
 
7221
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
7222
- eventless_wallet_session: true,
7223
- });
6904
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
7224
6905
  });
7225
6906
 
7226
6907
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {
@@ -7296,29 +6977,6 @@ describe('Protocol V2 current low-level methods', () => {
7296
6977
 
7297
6978
  await expect(method.run()).resolves.toBe(features);
7298
6979
  expect(unlockDevice).toHaveBeenCalledTimes(1);
7299
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
7300
- emitUiEvent: false,
7301
- });
7302
- });
7303
-
7304
- test('forwards an explicit PIN type when unlocking a Protocol V2 device', async () => {
7305
- const method = new DeviceUnlock({
7306
- id: 1,
7307
- payload: {
7308
- method: 'deviceUnlock',
7309
- pinType: DeviceSessionPinType.Any,
7310
- },
7311
- });
7312
- method.init();
7313
-
7314
- const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
7315
- (method as any).device = { unlockDevice };
7316
-
7317
- await method.run();
7318
-
7319
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, {
7320
- emitUiEvent: false,
7321
- });
7322
6980
  });
7323
6981
 
7324
6982
  test.each([