@onekeyfe/hd-core 1.2.0-alpha.65 → 1.2.0-alpha.66

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 (314) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +313 -0
  2. package/__tests__/DeviceCommands.test.ts +59 -13
  3. package/__tests__/DeviceEventRegistration.test.ts +6 -0
  4. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +198 -3
  5. package/__tests__/connectSettings.test.ts +5 -47
  6. package/__tests__/core-initialization.test.ts +23 -0
  7. package/__tests__/device-connector-protocol.test.ts +81 -0
  8. package/__tests__/device-lifecycle-events.test.ts +105 -3
  9. package/__tests__/device-pool-state.test.ts +22 -0
  10. package/__tests__/device-settings.test.ts +39 -18
  11. package/__tests__/device-state-contract.test.ts +1 -0
  12. package/__tests__/device-state-mapper.test.ts +13 -1
  13. package/__tests__/device-utils.test.ts +48 -1
  14. package/__tests__/deviceSettings.test.ts +11 -1
  15. package/__tests__/deviceUploadNft.test.ts +316 -0
  16. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +31 -59
  17. package/__tests__/get-device-state.test.ts +132 -12
  18. package/__tests__/homescreen.test.ts +41 -1
  19. package/__tests__/kaspa-use-tweak-pro2.test.ts +2 -2
  20. package/__tests__/kaspaSignTransaction.test.ts +3 -26
  21. package/__tests__/logBlockEvent.test.ts +1 -1
  22. package/__tests__/method-protocol-support.test.ts +19 -0
  23. package/__tests__/open-wallet-session.test.ts +568 -37
  24. package/__tests__/pro2Nft.test.ts +108 -0
  25. package/__tests__/protocol-binding.test.ts +89 -0
  26. package/__tests__/protocol-v2-resources.test.ts +340 -0
  27. package/__tests__/protocol-v2-ui-lifecycle.test.ts +91 -0
  28. package/__tests__/protocol-v2-unlock-policy.test.ts +300 -37
  29. package/__tests__/protocol-v2.test.ts +1506 -1097
  30. package/__tests__/protocolV2FileWrite.test.ts +152 -0
  31. package/__tests__/protocolV2UiInteraction.test.ts +63 -0
  32. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  33. package/__tests__/search-devices.test.ts +12 -3
  34. package/__tests__/tron-sign-message-init.test.ts +2 -2
  35. package/__tests__/uiProgressThrottle.test.ts +74 -0
  36. package/__tests__/uiPromiseRegistry.test.ts +115 -0
  37. package/dist/api/BaseMethod.d.ts +3 -1
  38. package/dist/api/BaseMethod.d.ts.map +1 -1
  39. package/dist/api/CheckAllFirmwareRelease.d.ts +2 -1
  40. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  41. package/dist/api/DetectDeviceConnectProtocol.d.ts +7 -0
  42. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +1 -0
  43. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  44. package/dist/api/FirmwareUpdateV2.d.ts +0 -11
  45. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  46. package/dist/api/FirmwareUpdateV3.d.ts +3 -7
  47. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  48. package/dist/api/FirmwareUpdateV4.d.ts +13 -27
  49. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  50. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  51. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  52. package/dist/api/SearchDevices.d.ts.map +1 -1
  53. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  54. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  55. package/dist/api/device/DeviceUnlock.d.ts +2 -2
  56. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  57. package/dist/api/device/DeviceUpdateBootloader.d.ts +1 -5
  58. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  59. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +4 -11
  60. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  61. package/dist/api/firmware/getBinary.d.ts +0 -7
  62. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  63. package/dist/api/firmware/updateBootloader.d.ts +0 -2
  64. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  65. package/dist/api/firmware/uploadFirmware.d.ts +1 -9
  66. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  67. package/dist/api/helpers/protocolV2FileWrite.d.ts +9 -0
  68. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  69. package/dist/api/index.d.ts +2 -0
  70. package/dist/api/index.d.ts.map +1 -1
  71. package/dist/api/kaspa/KaspaGetAddress.d.ts +1 -1
  72. package/dist/api/kaspa/KaspaSignTransaction.d.ts +1 -1
  73. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  74. package/dist/api/polkadot/PolkadotGetAddress.d.ts +1 -1
  75. package/dist/api/polkadot/networks.d.ts +1 -1
  76. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +31 -0
  77. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  78. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  79. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  80. package/dist/api/solana/SolSignMessage.d.ts +1 -1
  81. package/dist/api/solana/SolSignOffchainMessage.d.ts +1 -1
  82. package/dist/api/solana/SolSignTransaction.d.ts +2 -2
  83. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  84. package/dist/api/sui/SuiGetAddress.d.ts +1 -1
  85. package/dist/api/sui/SuiGetPublicKey.d.ts +1 -1
  86. package/dist/api/sui/SuiSignMessage.d.ts +1 -1
  87. package/dist/api/sui/SuiSignTransaction.d.ts +1 -1
  88. package/dist/api/ton/TonGetAddress.d.ts +1 -1
  89. package/dist/api/ton/TonSignMessage.d.ts +1 -1
  90. package/dist/api/ton/TonSignProof.d.ts +1 -1
  91. package/dist/api/tron/TronSignMessage.d.ts +2 -2
  92. package/dist/api/tron/TronSignTransaction.d.ts +1 -1
  93. package/dist/core/deviceEventRegistration.d.ts +2 -0
  94. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  95. package/dist/core/index.d.ts.map +1 -1
  96. package/dist/core/uiPromiseRegistry.d.ts +6 -0
  97. package/dist/core/uiPromiseRegistry.d.ts.map +1 -0
  98. package/dist/data-manager/DataManager.d.ts +9 -4
  99. package/dist/data-manager/DataManager.d.ts.map +1 -1
  100. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  101. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  102. package/dist/device/Device.d.ts +29 -6
  103. package/dist/device/Device.d.ts.map +1 -1
  104. package/dist/device/DeviceCommands.d.ts +4 -4
  105. package/dist/device/DeviceCommands.d.ts.map +1 -1
  106. package/dist/device/DeviceConnector.d.ts +2 -1
  107. package/dist/device/DeviceConnector.d.ts.map +1 -1
  108. package/dist/device/DevicePool.d.ts.map +1 -1
  109. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts.map +1 -1
  110. package/dist/events/device.d.ts +4 -0
  111. package/dist/events/device.d.ts.map +1 -1
  112. package/dist/events/logBlockEvent.d.ts.map +1 -1
  113. package/dist/events/ui-promise.d.ts +4 -2
  114. package/dist/events/ui-promise.d.ts.map +1 -1
  115. package/dist/events/ui-request.d.ts +31 -2
  116. package/dist/events/ui-request.d.ts.map +1 -1
  117. package/dist/events/ui-response.d.ts +10 -2
  118. package/dist/events/ui-response.d.ts.map +1 -1
  119. package/dist/index.d.ts +243 -248
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +3051 -3774
  122. package/dist/inject.d.ts +6 -1
  123. package/dist/inject.d.ts.map +1 -1
  124. package/dist/lowLevelInject.d.ts.map +1 -1
  125. package/dist/protocols/protocol-v2/features.d.ts +0 -4
  126. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  127. package/dist/protocols/protocol-v2/firmware.d.ts.map +1 -1
  128. package/dist/protocols/protocol-v2/resources.d.ts +30 -0
  129. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -0
  130. package/dist/protocols/protocol-v2/uiInteraction.d.ts +7 -4
  131. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  132. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  133. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  134. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  135. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  136. package/dist/protocols/protocol-v2/walletSession.d.ts +6 -1
  137. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  138. package/dist/topLevelInject.d.ts.map +1 -1
  139. package/dist/types/api/checkAllFirmwareRelease.d.ts +3 -5
  140. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  141. package/dist/types/api/detectDeviceConnectProtocol.d.ts +4 -0
  142. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +1 -0
  143. package/dist/types/api/deviceUnlock.d.ts +5 -1
  144. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  145. package/dist/types/api/deviceUpdateBootloader.d.ts +0 -6
  146. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  147. package/dist/types/api/export.d.ts +2 -4
  148. package/dist/types/api/export.d.ts.map +1 -1
  149. package/dist/types/api/firmwareUpdate.d.ts +4 -71
  150. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  151. package/dist/types/api/index.d.ts +7 -10
  152. package/dist/types/api/index.d.ts.map +1 -1
  153. package/dist/types/api/protocolV2.d.ts +3 -0
  154. package/dist/types/api/protocolV2.d.ts.map +1 -1
  155. package/dist/types/device.d.ts +2 -2
  156. package/dist/types/device.d.ts.map +1 -1
  157. package/dist/types/params.d.ts +1 -0
  158. package/dist/types/params.d.ts.map +1 -1
  159. package/dist/types/settings.d.ts +38 -21
  160. package/dist/types/settings.d.ts.map +1 -1
  161. package/dist/utils/deviceFeaturesCompat.d.ts +0 -1
  162. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  163. package/dist/utils/deviceFeaturesUtils.d.ts +4 -0
  164. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  165. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  166. package/dist/utils/deviceSettings.d.ts.map +1 -1
  167. package/dist/utils/homescreen.d.ts +4 -0
  168. package/dist/utils/homescreen.d.ts.map +1 -1
  169. package/dist/utils/index.d.ts +1 -1
  170. package/dist/utils/index.d.ts.map +1 -1
  171. package/dist/utils/patch.d.ts +1 -1
  172. package/dist/utils/patch.d.ts.map +1 -1
  173. package/dist/utils/pro2Nft.d.ts +31 -0
  174. package/dist/utils/pro2Nft.d.ts.map +1 -0
  175. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  176. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  177. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  178. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  179. package/package.json +4 -4
  180. package/src/api/BaseMethod.ts +8 -10
  181. package/src/api/CheckAllFirmwareRelease.ts +59 -60
  182. package/src/api/DetectDeviceConnectProtocol.ts +18 -0
  183. package/src/api/FirmwareUpdate.ts +7 -15
  184. package/src/api/FirmwareUpdateV2.ts +126 -316
  185. package/src/api/FirmwareUpdateV3.ts +63 -265
  186. package/src/api/FirmwareUpdateV4.ts +597 -1005
  187. package/src/api/GetPassphraseState.ts +7 -1
  188. package/src/api/OpenWalletSession.ts +71 -17
  189. package/src/api/SearchDevices.ts +3 -1
  190. package/src/api/allnetwork/AllNetworkGetAddress.ts +21 -21
  191. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +36 -11
  192. package/src/api/device/DeviceSettings.ts +5 -4
  193. package/src/api/device/DeviceUnlock.ts +8 -3
  194. package/src/api/device/DeviceUpdateBootloader.ts +6 -125
  195. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +27 -83
  196. package/src/api/firmware/updateBootloader.ts +4 -19
  197. package/src/api/firmware/uploadFirmware.ts +39 -144
  198. package/src/api/helpers/protocolV2FileWrite.ts +202 -63
  199. package/src/api/index.ts +2 -0
  200. package/src/api/kaspa/KaspaGetAddress.ts +1 -1
  201. package/src/api/kaspa/KaspaSignTransaction.ts +2 -13
  202. package/src/api/polkadot/networks.ts +3 -3
  203. package/src/api/protocol-v2/DeviceUploadNft.ts +197 -0
  204. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  205. package/src/api/protocol-v2/helpers.ts +1 -1
  206. package/src/api/solana/SolSignMessage.ts +1 -1
  207. package/src/api/solana/SolSignOffchainMessage.ts +1 -1
  208. package/src/api/solana/SolSignTransaction.ts +2 -2
  209. package/src/api/sui/SuiGetAddress.ts +1 -1
  210. package/src/api/sui/SuiGetPublicKey.ts +1 -1
  211. package/src/api/sui/SuiSignMessage.ts +1 -1
  212. package/src/api/sui/SuiSignTransaction.ts +1 -1
  213. package/src/api/ton/TonGetAddress.ts +1 -1
  214. package/src/api/ton/TonSignMessage.ts +1 -1
  215. package/src/api/ton/TonSignProof.ts +1 -1
  216. package/src/api/tron/TronSignMessage.ts +2 -2
  217. package/src/api/tron/TronSignTransaction.ts +1 -1
  218. package/src/core/deviceEventRegistration.ts +4 -0
  219. package/src/core/index.ts +213 -111
  220. package/src/core/uiPromiseRegistry.ts +63 -0
  221. package/src/data/messages/messages-protocol-v2.json +25 -0
  222. package/src/data-manager/DataManager.ts +113 -41
  223. package/src/data-manager/TransportManager.ts +6 -0
  224. package/src/data-manager/connectSettings.ts +0 -11
  225. package/src/device/Device.ts +274 -49
  226. package/src/device/DeviceCommands.ts +31 -4
  227. package/src/device/DeviceConnector.ts +33 -13
  228. package/src/device/DevicePool.ts +21 -7
  229. package/src/deviceProfile/buildDeviceFeatures.ts +2 -2
  230. package/src/deviceProfile/protocolV2DeviceIdentity.ts +3 -0
  231. package/src/events/device.ts +4 -0
  232. package/src/events/logBlockEvent.ts +1 -0
  233. package/src/events/ui-promise.ts +4 -2
  234. package/src/events/ui-request.ts +36 -2
  235. package/src/events/ui-response.ts +12 -2
  236. package/src/index.ts +0 -5
  237. package/src/inject.ts +60 -24
  238. package/src/lowLevelInject.ts +7 -8
  239. package/src/protocols/protocol-v2/features.ts +10 -7
  240. package/src/protocols/protocol-v2/firmware.ts +2 -0
  241. package/src/protocols/protocol-v2/resources.ts +390 -0
  242. package/src/protocols/protocol-v2/uiInteraction.ts +46 -6
  243. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  244. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +131 -0
  245. package/src/protocols/protocol-v2/walletSession.ts +214 -36
  246. package/src/topLevelInject.ts +7 -8
  247. package/src/types/api/checkAllFirmwareRelease.ts +3 -5
  248. package/src/types/api/detectDeviceConnectProtocol.ts +6 -0
  249. package/src/types/api/deviceUnlock.ts +12 -1
  250. package/src/types/api/deviceUpdateBootloader.ts +0 -6
  251. package/src/types/api/export.ts +1 -18
  252. package/src/types/api/firmwareUpdate.ts +6 -79
  253. package/src/types/api/index.ts +13 -14
  254. package/src/types/api/protocolV2.ts +13 -0
  255. package/src/types/device.ts +15 -3
  256. package/src/types/params.ts +7 -1
  257. package/src/types/settings.ts +60 -22
  258. package/src/utils/deviceFeaturesCompat.ts +5 -6
  259. package/src/utils/deviceFeaturesUtils.ts +14 -3
  260. package/src/utils/deviceInfoUtils.ts +2 -0
  261. package/src/utils/deviceSettings.ts +3 -0
  262. package/src/utils/homescreen.ts +32 -6
  263. package/src/utils/index.ts +6 -1
  264. package/src/utils/pro2Nft.ts +142 -0
  265. package/src/utils/pro2Wallpaper.ts +35 -8
  266. package/src/utils/uiProgressThrottle.ts +63 -0
  267. package/__tests__/device-initialize-timeout.test.ts +0 -56
  268. package/__tests__/firmware-update/check-all-firmware-release.test.ts +0 -175
  269. package/__tests__/firmware-update/firmware-artifact-source.test.ts +0 -119
  270. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -70
  271. package/__tests__/firmware-update/firmware-preparation-error.test.ts +0 -27
  272. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +0 -200
  273. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +0 -13
  274. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +0 -394
  275. package/__tests__/firmware-update/firmware-update-plan.test.ts +0 -593
  276. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +0 -213
  277. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +0 -58
  278. package/__tests__/firmware-update/firmware-upload-source.test.ts +0 -70
  279. package/dist/api/firmware/FirmwareArtifactSource.d.ts +0 -25
  280. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +0 -1
  281. package/dist/api/firmware/FirmwareHostBinding.d.ts +0 -6
  282. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +0 -1
  283. package/dist/api/firmware/FirmwarePreparationError.d.ts +0 -3
  284. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +0 -1
  285. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +0 -3
  286. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +0 -1
  287. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -25
  288. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +0 -1
  289. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +0 -32
  290. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +0 -1
  291. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  292. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  293. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  294. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  295. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  296. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  297. package/dist/types/api/firmwareUpdateCapabilities.d.ts +0 -9
  298. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +0 -1
  299. package/dist/types/api/firmwareUpdatePlan.d.ts +0 -28
  300. package/dist/types/api/firmwareUpdatePlan.d.ts.map +0 -1
  301. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +0 -42
  302. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +0 -1
  303. package/src/api/firmware/FirmwareArtifactSource.ts +0 -278
  304. package/src/api/firmware/FirmwareHostBinding.ts +0 -100
  305. package/src/api/firmware/FirmwarePreparationError.ts +0 -12
  306. package/src/api/firmware/FirmwareUpdateCapabilities.ts +0 -9
  307. package/src/api/firmware/FirmwareUpdatePlan.ts +0 -772
  308. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +0 -449
  309. package/src/api/firmware/progressThrottle.ts +0 -44
  310. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  311. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
  312. package/src/types/api/firmwareUpdateCapabilities.ts +0 -9
  313. package/src/types/api/firmwareUpdatePlan.ts +0 -38
  314. package/src/types/api/firmwareUpdatePreparedPlan.ts +0 -53
@@ -1,10 +1,11 @@
1
- import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
  import {
3
3
  DeviceRebootType,
4
4
  DeviceSessionPinType,
5
5
  DeviceSettingsPage,
6
6
  DeviceType,
7
7
  } from '@onekeyfe/hd-transport';
8
+ import { sha256 } from '@noble/hashes/sha256';
8
9
 
9
10
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
10
11
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -61,6 +62,7 @@ import { createCoreApi } from '../src/inject';
61
62
  import { Device, preloadSessionCache } from '../src/device/Device';
62
63
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
63
64
  import { DevicePool } from '../src/device/DevicePool';
65
+ import { DEVICE } from '../src/events';
64
66
  import { UI_REQUEST } from '../src/events/ui-request';
65
67
  import {
66
68
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -74,11 +76,15 @@ import {
74
76
  requestProtocolV2ProtocolInfo,
75
77
  supportsProtocolV2Message,
76
78
  } from '../src/protocols/protocol-v2/features';
79
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
77
80
  import {
78
81
  getProtocolV2WalletSession,
79
82
  refreshProtocolV2DeviceStatus,
80
83
  } from '../src/protocols/protocol-v2/walletSession';
81
- import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
84
+ import {
85
+ createProtocolV2UnlockContext,
86
+ runMethodWithUnlockPolicy,
87
+ } from '../src/protocols/protocol-v2/unlockPolicyRunner';
82
88
  import { BaseMethod } from '../src/api/BaseMethod';
83
89
  import {
84
90
  buildProtocolV1FeaturesPayload,
@@ -92,7 +98,6 @@ import {
92
98
  getMethodVersionRange,
93
99
  } from '../src/utils';
94
100
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
- import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
96
101
  import {
97
102
  getPassphraseState,
98
103
  getPassphraseStateWithRefreshDeviceInfo,
@@ -241,7 +246,7 @@ describe('UploadPortfolio', () => {
241
246
  (method as any).device = device;
242
247
 
243
248
  method.init();
244
- await runMethodWithUnlockRetry(method, device as any);
249
+ await runMethodWithUnlockPolicy(method, device as any);
245
250
 
246
251
  expect(unlockDevice).not.toHaveBeenCalled();
247
252
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -417,14 +422,16 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
417
422
  }
418
423
  return d.features;
419
424
  };
425
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
426
+ requestProtocolV2ProtocolInfo({
427
+ commands: d.getCommands?.() ?? d.commands,
428
+ timeoutMs,
429
+ });
420
430
  d.probeProtocolV2RuntimeState ??= async (
421
431
  deviceInfo: ProtocolV2DeviceInfo,
422
432
  timeoutMs?: number
423
433
  ) => {
424
- const protocolInfo = await requestProtocolV2ProtocolInfo({
425
- commands: d.getCommands(),
426
- timeoutMs,
427
- });
434
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
428
435
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
429
436
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
430
437
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -582,7 +589,7 @@ describe('Protocol V2 feature adapter', () => {
582
589
  } as any);
583
590
  const protocolV2 = buildProtocolV2FeaturesPayload({
584
591
  deviceInfo: {
585
- hw: { serial_no: 'P2-001' },
592
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'P2-001' },
586
593
  coprocessor: { bt_adv_name: 'Pro 2 BLE' },
587
594
  },
588
595
  deviceStatus: {
@@ -781,6 +788,9 @@ describe('Protocol V2 feature adapter', () => {
781
788
  if (request === 'DeviceStatusGet') {
782
789
  return { type: 'DeviceStatus', message: { unlocked: true } };
783
790
  }
791
+ if (request === 'DeviceSessionAskPassphrase') {
792
+ return { type: 'Success', message: {} };
793
+ }
784
794
  if (request === 'DeviceSessionGet') {
785
795
  return {
786
796
  type: 'DeviceSession',
@@ -805,7 +815,7 @@ describe('Protocol V2 feature adapter', () => {
805
815
  ).resolves.toEqual({
806
816
  passphraseState: 'state-1',
807
817
  newSession: 'session-1',
808
- unlockedAttachPin: undefined,
818
+ unlockedAttachPin: false,
809
819
  resumed: false,
810
820
  });
811
821
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -814,7 +824,13 @@ describe('Protocol V2 feature adapter', () => {
814
824
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
815
825
  type: DeviceSessionPinType.Main,
816
826
  });
817
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
827
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
828
+ passphrase: '',
829
+ on_device: false,
830
+ });
831
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
832
+ seed_domains: [],
833
+ });
818
834
  device.passphraseState = 'state-1';
819
835
  expect(device.getInternalState()).toBe('session-1');
820
836
  });
@@ -847,6 +863,7 @@ describe('Protocol V2 feature adapter', () => {
847
863
  },
848
864
  };
849
865
  }
866
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
850
867
  if (request === 'DeviceSessionGet') {
851
868
  return {
852
869
  message: {
@@ -863,9 +880,16 @@ describe('Protocol V2 feature adapter', () => {
863
880
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
864
881
 
865
882
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
866
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
883
+ expect(
884
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
885
+ ).toHaveLength(1);
886
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
887
+ seed_domains: [],
888
+ });
867
889
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
868
890
  session_id: 'standard-session',
891
+ btc_test_address: 'standard-state',
892
+ seed_domains: [],
869
893
  });
870
894
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
871
895
  passphraseState: 'standard-state',
@@ -907,6 +931,7 @@ describe('Protocol V2 feature adapter', () => {
907
931
  },
908
932
  };
909
933
  }
934
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
910
935
  if (request === 'DeviceSessionAskPin') {
911
936
  return { type: 'Success', message: { message: 'PIN verified' } };
912
937
  }
@@ -1001,6 +1026,7 @@ describe('Protocol V2 feature adapter', () => {
1001
1026
  if (request === 'DeviceStatusGet') {
1002
1027
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1003
1028
  }
1029
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1004
1030
  if (request === 'DeviceSessionGet') {
1005
1031
  sessionGetCalls += 1;
1006
1032
  return {
@@ -1227,7 +1253,9 @@ describe('Protocol V2 feature adapter', () => {
1227
1253
  passphrase: 'host hidden wallet',
1228
1254
  on_device: false,
1229
1255
  });
1230
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1256
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1257
+ seed_domains: [],
1258
+ });
1231
1259
  });
1232
1260
 
1233
1261
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1335,6 +1363,8 @@ describe('Protocol V2 feature adapter', () => {
1335
1363
 
1336
1364
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1337
1365
  session_id: 'session-a',
1366
+ btc_test_address: 'state-a',
1367
+ seed_domains: [],
1338
1368
  });
1339
1369
  expect(device.getInternalState()).toBe('session-b');
1340
1370
  });
@@ -1402,7 +1432,15 @@ describe('Protocol V2 feature adapter', () => {
1402
1432
  });
1403
1433
  expect(typedCall.mock.calls).toEqual([
1404
1434
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1405
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1435
+ [
1436
+ 'DeviceSessionGet',
1437
+ 'DeviceSession',
1438
+ {
1439
+ session_id: 'session-a',
1440
+ btc_test_address: 'state-a',
1441
+ seed_domains: [],
1442
+ },
1443
+ ],
1406
1444
  ]);
1407
1445
  expect(promptPassphrase).not.toHaveBeenCalled();
1408
1446
  expect(device.getInternalState()).toBeUndefined();
@@ -1450,7 +1488,7 @@ describe('Protocol V2 feature adapter', () => {
1450
1488
  { passphrase: 'host hidden wallet', on_device: false },
1451
1489
  ],
1452
1490
  ['DeviceStatusGet', 'DeviceStatus', {}],
1453
- ['DeviceSessionGet', 'DeviceSession', {}],
1491
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1454
1492
  ]);
1455
1493
  });
1456
1494
 
@@ -1553,6 +1591,73 @@ describe('Protocol V2 feature adapter', () => {
1553
1591
  expect(device.getInternalState()).toBeUndefined();
1554
1592
  });
1555
1593
 
1594
+ test.each([false, true])(
1595
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1596
+ async lockFails => {
1597
+ const deviceId = 'attach-mismatch-device';
1598
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1599
+ (device as any).features = normalizeProtocolV2Features(
1600
+ { ...descriptor, protocolType: 'V2' } as any,
1601
+ {
1602
+ status: {
1603
+ device_id: deviceId,
1604
+ unlocked: true,
1605
+ passphrase_enabled: true,
1606
+ attach_to_pin_enabled: true,
1607
+ unlocked_by_attach_to_pin: false,
1608
+ },
1609
+ }
1610
+ );
1611
+ device.passphraseState = 'expected-state';
1612
+
1613
+ const typedCall = jest.fn((request: string) => {
1614
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1615
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1616
+ if (request === 'DeviceStatusGet') {
1617
+ return {
1618
+ message: {
1619
+ device_id: deviceId,
1620
+ unlocked: true,
1621
+ passphrase_enabled: true,
1622
+ attach_to_pin_enabled: true,
1623
+ unlocked_by_attach_to_pin: true,
1624
+ },
1625
+ };
1626
+ }
1627
+ if (request === 'DeviceSessionGet') {
1628
+ return {
1629
+ message: {
1630
+ btc_test_address: 'wrong-attach-state',
1631
+ session_id: 'wrong-attach-session',
1632
+ },
1633
+ };
1634
+ }
1635
+ if (request === 'LockDevice') {
1636
+ if (lockFails) throw new Error('Lock unsupported');
1637
+ return { message: { message: 'Device locked' } };
1638
+ }
1639
+ throw new Error(`Unexpected request: ${request}`);
1640
+ });
1641
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1642
+ (device as any).commands = { typedCall, promptPassphrase };
1643
+
1644
+ await expect(
1645
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1646
+ ).rejects.toMatchObject({
1647
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1648
+ });
1649
+
1650
+ expect(promptPassphrase).not.toHaveBeenCalled();
1651
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1652
+ btc_test_address: 'expected-state',
1653
+ seed_domains: [],
1654
+ });
1655
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1656
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1657
+ expect(device.getInternalState()).toBeUndefined();
1658
+ }
1659
+ );
1660
+
1556
1661
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1557
1662
  const device = Device.fromDescriptor({
1558
1663
  id: 'cache-device-attach-recovery',
@@ -1609,7 +1714,17 @@ describe('Protocol V2 feature adapter', () => {
1609
1714
  }),
1610
1715
  { cancelDeviceOnReject: false }
1611
1716
  );
1612
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1717
+ expect(unlockDevice).toHaveBeenCalledWith(
1718
+ DeviceSessionPinType.AttachToPin,
1719
+ expect.objectContaining({
1720
+ emitUiEvent: false,
1721
+ interaction: expect.objectContaining({
1722
+ phase: 'pin',
1723
+ transition: 'start',
1724
+ protocol: 'V2',
1725
+ }),
1726
+ })
1727
+ );
1613
1728
  expect(device.getInternalState()).toBe('new-attach-session');
1614
1729
  });
1615
1730
 
@@ -1893,12 +2008,10 @@ describe('Protocol V2 feature adapter', () => {
1893
2008
  expect(getFeatures).not.toHaveBeenCalled();
1894
2009
  });
1895
2010
 
1896
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
2011
+ test('uses the cached wallet address when the local session is missing', async () => {
1897
2012
  const typedCall = jest
1898
2013
  .fn()
1899
2014
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1902
2015
  .mockResolvedValueOnce({
1903
2016
  message: {
1904
2017
  btc_test_address: 'expected-state',
@@ -1926,16 +2039,11 @@ describe('Protocol V2 feature adapter', () => {
1926
2039
  await expect(
1927
2040
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1928
2041
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
1929
- expect(promptPassphrase).toHaveBeenCalledWith(
1930
- {
1931
- existsAttachPinUser: false,
1932
- deviceOnly: false,
1933
- source: 'wallet-session-coordinator',
1934
- reason: 'session-recovery',
1935
- expectedPassphraseState: 'expected-state',
1936
- },
1937
- { cancelDeviceOnReject: false }
1938
- );
2042
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2043
+ btc_test_address: 'expected-state',
2044
+ seed_domains: [],
2045
+ });
2046
+ expect(promptPassphrase).not.toHaveBeenCalled();
1939
2047
  });
1940
2048
 
1941
2049
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2001,8 +2109,15 @@ describe('Protocol V2 feature adapter', () => {
2001
2109
  true
2002
2110
  );
2003
2111
  expect(typedCall.mock.calls).toEqual([
2004
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2112
+ [
2113
+ 'DeviceSessionGet',
2114
+ 'DeviceSession',
2115
+ {
2116
+ session_id: 'session-pro2-app',
2117
+ btc_test_address: 'state-pro2-app',
2118
+ seed_domains: [],
2119
+ },
2120
+ ],
2006
2121
  ]);
2007
2122
  });
2008
2123
 
@@ -2191,7 +2306,9 @@ describe('Protocol V2 feature adapter', () => {
2191
2306
  passphrase: 'host hidden wallet',
2192
2307
  on_device: false,
2193
2308
  });
2194
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2309
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2310
+ seed_domains: [],
2311
+ });
2195
2312
  });
2196
2313
 
2197
2314
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2269,12 +2386,83 @@ describe('Protocol V2 feature adapter', () => {
2269
2386
  );
2270
2387
 
2271
2388
  expect(device.getInternalState()).toBeUndefined();
2272
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2389
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2390
+ btc_test_address: 'expected-state',
2391
+ seed_domains: [],
2392
+ });
2393
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2273
2394
  passphrase: 'host hidden wallet',
2274
2395
  on_device: false,
2275
2396
  });
2276
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2397
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2398
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2399
+ seed_domains: [],
2400
+ });
2401
+ });
2402
+
2403
+ test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
2404
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2405
+ const typedCall = jest.fn((request: string) => {
2406
+ if (request === 'ProtocolInfoRequest') {
2407
+ return { message: { version: 2 } };
2408
+ }
2409
+ if (request === 'DeviceStatusGet') {
2410
+ return {
2411
+ message: {
2412
+ device_id: 'attach-wallet-device',
2413
+ unlocked: true,
2414
+ passphrase_enabled: true,
2415
+ attach_to_pin_enabled: true,
2416
+ unlocked_by_attach_to_pin: true,
2417
+ },
2418
+ };
2419
+ }
2420
+ if (request === 'DeviceSessionAskPin' || request === 'DeviceSessionAskPassphrase') {
2421
+ return { message: { message: 'Wallet selected' } };
2422
+ }
2423
+ if (request === 'DeviceSessionGet') {
2424
+ return {
2425
+ message: {
2426
+ session_id: 'main-wallet-session',
2427
+ btc_test_address: 'main-wallet-state',
2428
+ },
2429
+ };
2430
+ }
2431
+ if (request === 'LockDevice') {
2432
+ return { message: { message: 'Device locked' } };
2433
+ }
2434
+ throw new Error(`Unexpected request: ${request}`);
2435
+ });
2436
+
2437
+ (device as any).features = normalizeProtocolV2Features(
2438
+ { ...descriptor, protocolType: 'V2' } as any,
2439
+ {
2440
+ status: {
2441
+ device_id: 'attach-wallet-device',
2442
+ unlocked: true,
2443
+ passphrase_enabled: true,
2444
+ attach_to_pin_enabled: true,
2445
+ // 模拟缓存滞后:设备状态刷新后才确认当前由 Attach PIN 解锁。
2446
+ unlocked_by_attach_to_pin: false,
2447
+ },
2448
+ }
2449
+ );
2450
+ (device as any).commands = { typedCall };
2451
+ const clearInternalState = jest.spyOn(device, 'clearInternalState');
2452
+
2453
+ await expect(device.checkPassphraseStateSafety(undefined, true, false)).rejects.toMatchObject({
2454
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2455
+ });
2456
+
2457
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2458
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
2459
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2460
+ expect(typedCall).not.toHaveBeenCalledWith(
2461
+ 'DeviceSessionAskPassphrase',
2462
+ 'Success',
2463
+ expect.anything()
2464
+ );
2465
+ expect(clearInternalState).toHaveBeenCalledTimes(1);
2278
2466
  });
2279
2467
 
2280
2468
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2307,10 +2495,22 @@ describe('Protocol V2 feature adapter', () => {
2307
2495
  await expect(
2308
2496
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2309
2497
  ).resolves.toBe(true);
2310
- expect(typedCall).toHaveBeenCalledTimes(4);
2498
+ expect(typedCall).toHaveBeenCalledTimes(5);
2311
2499
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2312
2500
  eventless_wallet_session: true,
2313
2501
  });
2502
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2503
+ seed_domains: [],
2504
+ });
2505
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2506
+ passphrase: '',
2507
+ on_device: false,
2508
+ });
2509
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2510
+ expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
2511
+ 2
2512
+ );
2513
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2314
2514
  });
2315
2515
 
2316
2516
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2381,7 +2581,7 @@ describe('Protocol V2 feature adapter', () => {
2381
2581
  uuid: 'CACHED-SERIAL',
2382
2582
  deviceId: null,
2383
2583
  bleName: 'Cached BLE',
2384
- name: 'Cached Label',
2584
+ name: 'Cached BLE',
2385
2585
  label: 'Cached Label',
2386
2586
  deviceType: 'pro2',
2387
2587
  });
@@ -2408,7 +2608,7 @@ describe('Protocol V2 feature adapter', () => {
2408
2608
  expect(device.features?.label).toBeNull();
2409
2609
  });
2410
2610
 
2411
- test('prefers the real Protocol V2 label over the BLE name for display', () => {
2611
+ test('uses the BLE name for discovery while preserving the Protocol V2 label', () => {
2412
2612
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2413
2613
  (device as any).features = {
2414
2614
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
@@ -2416,9 +2616,10 @@ describe('Protocol V2 feature adapter', () => {
2416
2616
  bleName: 'Pro2 6136',
2417
2617
  };
2418
2618
 
2619
+ expect(device.getCurrentDisplayName()).toBe('My Pro 2');
2419
2620
  expect(device.toMessageObject()).toMatchObject({
2420
2621
  bleName: 'Pro2 6136',
2421
- name: 'My Pro 2',
2622
+ name: 'Pro2 6136',
2422
2623
  label: 'My Pro 2',
2423
2624
  });
2424
2625
  });
@@ -2472,10 +2673,6 @@ describe('Protocol V2 feature adapter', () => {
2472
2673
  hw: true,
2473
2674
  fw: true,
2474
2675
  coprocessor: true,
2475
- se1: true,
2476
- se2: true,
2477
- se3: true,
2478
- se4: true,
2479
2676
  },
2480
2677
  types: {
2481
2678
  version: true,
@@ -2499,8 +2696,10 @@ describe('Protocol V2 feature adapter', () => {
2499
2696
  ).rejects.toThrow('DeviceInfo not supported');
2500
2697
  });
2501
2698
 
2502
- test('does not inherit Pro or Pro model fallback ranges for Protocol V2 devices', () => {
2503
- const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any);
2699
+ test('does not inherit Pro or model_touch ranges for Protocol V2 devices', () => {
2700
+ const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2701
+ hw: { Device_type: DeviceType.PRO2 },
2702
+ });
2504
2703
  const checkedTypes: string[] = [];
2505
2704
 
2506
2705
  const versionRange = getMethodVersionRange(features, type => {
@@ -2512,11 +2711,13 @@ describe('Protocol V2 feature adapter', () => {
2512
2711
  });
2513
2712
 
2514
2713
  expect(versionRange).toBeUndefined();
2515
- expect(checkedTypes).toEqual(['pro2']);
2714
+ expect(checkedTypes).toEqual(['pro2', 'model_pro2']);
2516
2715
  });
2517
2716
 
2518
2717
  test('uses firmware-v1 as the Pro2 remote firmware config field', () => {
2519
- const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any);
2718
+ const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2719
+ hw: { Device_type: DeviceType.PRO2 },
2720
+ });
2520
2721
 
2521
2722
  expect(
2522
2723
  getFirmwareUpdateField({
@@ -2667,10 +2868,6 @@ describe('Protocol V2 feature adapter', () => {
2667
2868
  hw: true,
2668
2869
  fw: true,
2669
2870
  coprocessor: true,
2670
- se1: true,
2671
- se2: true,
2672
- se3: true,
2673
- se4: true,
2674
2871
  },
2675
2872
  types: {
2676
2873
  version: true,
@@ -2681,7 +2878,9 @@ describe('Protocol V2 feature adapter', () => {
2681
2878
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2682
2879
  }
2683
2880
  );
2684
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2881
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2882
+ eventless_wallet_session: true,
2883
+ });
2685
2884
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2686
2885
  });
2687
2886
 
@@ -2719,7 +2918,9 @@ describe('Protocol V2 feature adapter', () => {
2719
2918
  }),
2720
2919
  expect.anything()
2721
2920
  );
2722
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2921
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2922
+ eventless_wallet_session: true,
2923
+ });
2723
2924
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2724
2925
  expect(device.features).toMatchObject({
2725
2926
  deviceId: null,
@@ -2985,10 +3186,6 @@ describe('Protocol V2 feature adapter', () => {
2985
3186
  fw: { application: { version: '1.2.4' } },
2986
3187
  },
2987
3188
  })
2988
- .mockResolvedValueOnce({
2989
- type: 'ProtocolInfo',
2990
- message: protocolV2ApplicationInfo,
2991
- })
2992
3189
  .mockResolvedValueOnce({
2993
3190
  type: 'DeviceStatus',
2994
3191
  message: { init_states: true, unlocked: true },
@@ -3010,7 +3207,7 @@ describe('Protocol V2 feature adapter', () => {
3010
3207
  firmwareVersion: '1.2.4',
3011
3208
  passphraseProtection: null,
3012
3209
  });
3013
- expect(typedCall).toHaveBeenCalledTimes(6);
3210
+ expect(typedCall).toHaveBeenCalledTimes(5);
3014
3211
  expect(typedCall).toHaveBeenNthCalledWith(
3015
3212
  4,
3016
3213
  'DeviceInfoGet',
@@ -3020,10 +3217,6 @@ describe('Protocol V2 feature adapter', () => {
3020
3217
  hw: true,
3021
3218
  fw: true,
3022
3219
  coprocessor: true,
3023
- se1: true,
3024
- se2: true,
3025
- se3: true,
3026
- se4: true,
3027
3220
  },
3028
3221
  types: {
3029
3222
  version: true,
@@ -3124,9 +3317,23 @@ describe('Protocol V2 feature adapter', () => {
3124
3317
  status: { init_states: true },
3125
3318
  }
3126
3319
  );
3320
+ const pinOnDevice = jest.fn();
3321
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3127
3322
 
3128
3323
  const features = await device.unlockDevice();
3129
3324
 
3325
+ expect(pinOnDevice).toHaveBeenCalledWith(
3326
+ device,
3327
+ DeviceSessionPinType.Main,
3328
+ expect.objectContaining({
3329
+ source: 'unlock-coordinator',
3330
+ reason: 'device-unlock',
3331
+ deviceOnly: true,
3332
+ })
3333
+ );
3334
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3335
+ typedCall.mock.invocationCallOrder[0]
3336
+ );
3130
3337
  expect(typedCall.mock.calls).toEqual([
3131
3338
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3132
3339
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3172,9 +3379,12 @@ describe('Protocol V2 feature adapter', () => {
3172
3379
  throw new Error(`Unexpected request: ${requestType}`);
3173
3380
  });
3174
3381
  (device as any).commands = { typedCall };
3382
+ const pinOnDevice = jest.fn();
3383
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3175
3384
 
3176
- await device.unlockDevice();
3385
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3177
3386
 
3387
+ expect(pinOnDevice).not.toHaveBeenCalled();
3178
3388
  expect(typedCall.mock.calls).toEqual([
3179
3389
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3180
3390
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3417,71 +3627,77 @@ describe('API compatibility handling', () => {
3417
3627
  });
3418
3628
  });
3419
3629
 
3420
- test('allows Pro2 Solana signing methods through Protocol V2 version checks', () => {
3421
- const features = {
3422
- deviceType: 'pro2',
3423
- } as Features;
3630
+ test.each(['pro2', 'neo'] as const)(
3631
+ 'allows %s Solana signing methods through Protocol V2 version checks',
3632
+ deviceType => {
3633
+ const features = {
3634
+ deviceType,
3635
+ } as Features;
3424
3636
 
3425
- const solSignMessageMethod = new SolSignMessage({
3426
- id: 1,
3427
- payload: {
3428
- method: 'solSignMessage',
3429
- path: "m/44'/501'/0'/0'",
3430
- messageHex: '48656c6c6f',
3431
- },
3432
- });
3433
- const solSignOffchainMessageMethod = new SolSignOffchainMessage({
3434
- id: 2,
3435
- payload: {
3436
- method: 'solSignOffchainMessage',
3437
- path: "m/44'/501'/0'/0'",
3438
- messageHex: '48656c6c6f',
3439
- },
3440
- });
3441
- const solSignTransactionMethod = new SolSignTransaction({
3442
- id: 3,
3443
- payload: {
3444
- method: 'solSignTransaction',
3445
- path: "m/44'/501'/0'/0'",
3446
- rawTx: '00',
3447
- },
3448
- });
3449
- const solSignVersionedTransactionMethod = new SolSignTransaction({
3450
- id: 4,
3451
- payload: {
3452
- method: 'solSignTransaction',
3453
- path: "m/44'/501'/0'/0'",
3454
- rawTx: '80',
3455
- },
3456
- });
3637
+ const solSignMessageMethod = new SolSignMessage({
3638
+ id: 1,
3639
+ payload: {
3640
+ method: 'solSignMessage',
3641
+ path: "m/44'/501'/0'/0'",
3642
+ messageHex: '48656c6c6f',
3643
+ },
3644
+ });
3645
+ const solSignOffchainMessageMethod = new SolSignOffchainMessage({
3646
+ id: 2,
3647
+ payload: {
3648
+ method: 'solSignOffchainMessage',
3649
+ path: "m/44'/501'/0'/0'",
3650
+ messageHex: '48656c6c6f',
3651
+ },
3652
+ });
3653
+ const solSignTransactionMethod = new SolSignTransaction({
3654
+ id: 3,
3655
+ payload: {
3656
+ method: 'solSignTransaction',
3657
+ path: "m/44'/501'/0'/0'",
3658
+ rawTx: '00',
3659
+ },
3660
+ });
3661
+ const solSignVersionedTransactionMethod = new SolSignTransaction({
3662
+ id: 4,
3663
+ payload: {
3664
+ method: 'solSignTransaction',
3665
+ path: "m/44'/501'/0'/0'",
3666
+ rawTx: '80',
3667
+ },
3668
+ });
3457
3669
 
3458
- solSignTransactionMethod.init();
3459
- solSignVersionedTransactionMethod.init();
3670
+ solSignTransactionMethod.init();
3671
+ solSignVersionedTransactionMethod.init();
3460
3672
 
3461
- expect(
3462
- getMethodVersionRange(features, type => solSignMessageMethod.getVersionRange()[type])
3463
- ).toEqual({
3464
- min: '0.0.0',
3465
- });
3466
- expect(
3467
- getMethodVersionRange(features, type => solSignOffchainMessageMethod.getVersionRange()[type])
3468
- ).toEqual({
3469
- min: '0.0.0',
3470
- });
3471
- expect(
3472
- getMethodVersionRange(features, type => solSignTransactionMethod.getVersionRange()[type])
3473
- ).toEqual({
3474
- min: '0.0.0',
3475
- });
3476
- expect(
3477
- getMethodVersionRange(
3478
- features,
3479
- type => solSignVersionedTransactionMethod.getVersionRange()[type]
3480
- )
3481
- ).toEqual({
3482
- min: '0.0.0',
3483
- });
3484
- });
3673
+ expect(
3674
+ getMethodVersionRange(features, type => solSignMessageMethod.getVersionRange()[type])
3675
+ ).toEqual({
3676
+ min: '0.0.0',
3677
+ });
3678
+ expect(
3679
+ getMethodVersionRange(
3680
+ features,
3681
+ type => solSignOffchainMessageMethod.getVersionRange()[type]
3682
+ )
3683
+ ).toEqual({
3684
+ min: '0.0.0',
3685
+ });
3686
+ expect(
3687
+ getMethodVersionRange(features, type => solSignTransactionMethod.getVersionRange()[type])
3688
+ ).toEqual({
3689
+ min: '0.0.0',
3690
+ });
3691
+ expect(
3692
+ getMethodVersionRange(
3693
+ features,
3694
+ type => solSignVersionedTransactionMethod.getVersionRange()[type]
3695
+ )
3696
+ ).toEqual({
3697
+ min: '0.0.0',
3698
+ });
3699
+ }
3700
+ );
3485
3701
 
3486
3702
  test('uses chunk transfer for large Sui transactions on Protocol V2', async () => {
3487
3703
  const rawTx = '0x'.concat('ab'.repeat(5000));
@@ -3577,6 +3793,15 @@ describe('API compatibility handling', () => {
3577
3793
 
3578
3794
  describe('Protocol V2 firmware update targets', () => {
3579
3795
  const OKPP_HEADER_SIZE = 0x52a0;
3796
+ let forceReloadDataSpy: jest.SpyInstance;
3797
+
3798
+ beforeEach(() => {
3799
+ forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3800
+ });
3801
+
3802
+ afterEach(() => {
3803
+ jest.restoreAllMocks();
3804
+ });
3580
3805
 
3581
3806
  const buildOkppHeader = ({
3582
3807
  type = 'RESC',
@@ -3701,7 +3926,7 @@ describe('Protocol V2 firmware update targets', () => {
3701
3926
  specific: true,
3702
3927
  },
3703
3928
  },
3704
- { timeoutMs: 5000 }
3929
+ { timeoutMs: 30000 }
3705
3930
  );
3706
3931
  expect(typedCall).toHaveBeenCalledTimes(3);
3707
3932
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
@@ -3717,7 +3942,7 @@ describe('Protocol V2 firmware update targets', () => {
3717
3942
  });
3718
3943
  });
3719
3944
 
3720
- test('delegates Protocol V2 mode transition to the phase executor', async () => {
3945
+ test('enters Protocol V2 bootloader before upload', async () => {
3721
3946
  const method = new FirmwareUpdateV4({
3722
3947
  id: 1,
3723
3948
  payload: {
@@ -3752,7 +3977,7 @@ describe('Protocol V2 firmware update targets', () => {
3752
3977
 
3753
3978
  await method.run();
3754
3979
 
3755
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3980
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3756
3981
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3757
3982
  fwBinaryMap: [
3758
3983
  {
@@ -3763,19 +3988,86 @@ describe('Protocol V2 firmware update targets', () => {
3763
3988
  ],
3764
3989
  bootloaderBinary: null,
3765
3990
  });
3991
+ expect(method.postTipMessage).toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
3766
3992
  });
3767
3993
 
3768
- test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
3994
+ test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
3769
3995
  const method = new FirmwareUpdateV4({
3770
3996
  id: 1,
3771
3997
  payload: {
3772
3998
  method: 'firmwareUpdateV4',
3999
+ platform: 'web',
4000
+ targetsToUpdate: ['resource'],
3773
4001
  },
3774
4002
  });
4003
+ method.init();
4004
+
3775
4005
  (method as any).device = stubDevice({
3776
- originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
3777
- features: { bootloader_mode: false, capabilities: [] },
4006
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4007
+ features: {
4008
+ deviceType: 'pro2',
4009
+ firmwareVersion: '1.0.0',
4010
+ mode: 'normal',
4011
+ bootloaderMode: false,
4012
+ capabilities: [],
4013
+ },
3778
4014
  isBootloader: () => false,
4015
+ isRomloader: () => false,
4016
+ });
4017
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4018
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
4019
+ bootloaderBinary: null,
4020
+ fwBinaryMap: [],
4021
+ installItems: [],
4022
+ });
4023
+
4024
+ const order: string[] = [];
4025
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4026
+ order.push('enter-bootloader');
4027
+ return Promise.resolve(true);
4028
+ });
4029
+ (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
4030
+ order.push('prepare-resources');
4031
+ return Promise.resolve([
4032
+ {
4033
+ name: 'images.okpkg',
4034
+ binary: new Uint8Array([1]).buffer,
4035
+ devicePath: 'vol0:/bundles/images/images.okpkg',
4036
+ },
4037
+ ]);
4038
+ });
4039
+ (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4040
+ order.push('execute-update');
4041
+ return Promise.resolve();
4042
+ });
4043
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4044
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4045
+ bootloaderVersion: '1.0.0',
4046
+ bleVersion: '1.0.0',
4047
+ firmwareVersion: '1.0.0',
4048
+ });
4049
+ method.postTipMessage = jest.fn();
4050
+
4051
+ await method.run();
4052
+
4053
+ expect(order).toEqual(['enter-bootloader', 'prepare-resources', 'execute-update']);
4054
+ });
4055
+
4056
+ test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4057
+ const method = new FirmwareUpdateV4({
4058
+ id: 1,
4059
+ payload: {
4060
+ method: 'firmwareUpdateV4',
4061
+ },
4062
+ });
4063
+ (method as any).device = stubDevice({
4064
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4065
+ features: { bootloader_mode: false, capabilities: [] },
4066
+ isBootloader: () => false,
4067
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4068
+ mode: 'bootloader',
4069
+ bootloaderMode: true,
4070
+ }),
3779
4071
  });
3780
4072
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
3781
4073
  message: 'Device rebooted successfully',
@@ -3803,6 +4095,7 @@ describe('Protocol V2 firmware update targets', () => {
3803
4095
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3804
4096
  (method as any).device.getCommands = () => ({ typedCall });
3805
4097
  (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4098
+ (method as any).protocolV2ExpectedPath = 'usb-path';
3806
4099
  method.postTipMessage = jest.fn();
3807
4100
 
3808
4101
  await (method as any).enterProtocolV2BootloaderMode();
@@ -3814,6 +4107,66 @@ describe('Protocol V2 firmware update targets', () => {
3814
4107
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3815
4108
  });
3816
4109
 
4110
+ test('announces bootloader success only after Protocol V2 bootloader mode is confirmed', async () => {
4111
+ const method = new FirmwareUpdateV4({
4112
+ id: 1,
4113
+ payload: {
4114
+ method: 'firmwareUpdateV4',
4115
+ },
4116
+ });
4117
+ (method as any).device = stubDevice({
4118
+ features: { bootloader_mode: false, capabilities: [] },
4119
+ isBootloader: () => false,
4120
+ isRomloader: () => false,
4121
+ });
4122
+ (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4123
+ message: 'Device rebooted successfully',
4124
+ });
4125
+
4126
+ const tipMessages: string[] = [];
4127
+ method.postTipMessage = jest.fn((message: string) => {
4128
+ tipMessages.push(message);
4129
+ });
4130
+ let successWasAnnouncedBeforeConfirmation = false;
4131
+ (method as any).waitForProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4132
+ successWasAnnouncedBeforeConfirmation = tipMessages.includes('GoToBootloaderSuccess');
4133
+ return Promise.resolve({ mode: 'bootloader' });
4134
+ });
4135
+
4136
+ await (method as any).enterProtocolV2BootloaderMode();
4137
+
4138
+ expect(successWasAnnouncedBeforeConfirmation).toBe(false);
4139
+ expect(tipMessages).toEqual(['AutoRebootToBootloader', 'GoToBootloaderSuccess']);
4140
+ });
4141
+
4142
+ test('keeps Protocol V2 bootloader active before firmware transfer', async () => {
4143
+ const method = new FirmwareUpdateV4({
4144
+ id: 1,
4145
+ payload: {
4146
+ method: 'firmwareUpdateV4',
4147
+ },
4148
+ });
4149
+ (method as any).device = stubDevice({
4150
+ originalDescriptor: { id: 'usb-id', path: 'bootloader-path', protocolType: 'V2' },
4151
+ features: {
4152
+ deviceType: 'pro2',
4153
+ mode: 'bootloader',
4154
+ bootloaderMode: true,
4155
+ capabilities: [],
4156
+ },
4157
+ isBootloader: () => true,
4158
+ isRomloader: () => false,
4159
+ });
4160
+ (method as any).protocolV2Reboot = jest.fn();
4161
+ method.postTipMessage = jest.fn();
4162
+
4163
+ await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4164
+
4165
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4166
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
4167
+ expect(method.postTipMessage).toHaveBeenCalledWith('GoToBootloaderSuccess');
4168
+ });
4169
+
3817
4170
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
3818
4171
  const method = new FirmwareUpdateV4({
3819
4172
  id: 1,
@@ -3866,6 +4219,9 @@ describe('Protocol V2 firmware update targets', () => {
3866
4219
  },
3867
4220
  });
3868
4221
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4222
+ const prepareResourceBundles = jest
4223
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4224
+ .mockResolvedValue(undefined);
3869
4225
  (method as any).protocolV2Reboot = jest.fn();
3870
4226
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3871
4227
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -3878,6 +4234,7 @@ describe('Protocol V2 firmware update targets', () => {
3878
4234
 
3879
4235
  await method.run();
3880
4236
 
4237
+ expect(prepareResourceBundles).not.toHaveBeenCalled();
3881
4238
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3882
4239
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3883
4240
  bootloaderBinary: null,
@@ -3897,6 +4254,10 @@ describe('Protocol V2 firmware update targets', () => {
3897
4254
  originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
3898
4255
  features: { bootloader_mode: false, capabilities: [] },
3899
4256
  isBootloader: () => false,
4257
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4258
+ mode: 'bootloader',
4259
+ bootloaderMode: true,
4260
+ }),
3900
4261
  });
3901
4262
  const typedCall = jest.fn().mockImplementation((requestType: string) => {
3902
4263
  if (requestType === 'DeviceInfoGet') {
@@ -3924,15 +4285,13 @@ describe('Protocol V2 firmware update targets', () => {
3924
4285
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3925
4286
  (method as any).device.getCommands = () => ({ typedCall });
3926
4287
  (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4288
+ (method as any).protocolV2ExpectedPath = 'app-path';
3927
4289
 
3928
4290
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
3929
4291
 
3930
4292
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
3931
- expect(typedCall).toHaveBeenCalledTimes(2);
3932
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3933
- 'DeviceInfoGet',
3934
- 'ProtocolInfoRequest',
3935
- ]);
4293
+ expect(typedCall).toHaveBeenCalledTimes(1);
4294
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
3936
4295
  });
3937
4296
 
3938
4297
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -3960,12 +4319,16 @@ describe('Protocol V2 firmware update targets', () => {
3960
4319
  getCommands: () => commands,
3961
4320
  mainId: 'usb-path',
3962
4321
  });
3963
- const cachedDevice = { getConnectId: () => 'usb-path' };
4322
+ const cachedDevice = {
4323
+ getConnectId: () => 'usb-path',
4324
+ getCurrentSerialNo: () => '',
4325
+ };
3964
4326
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
3965
4327
  devices: {},
3966
4328
  deviceList: [cachedDevice],
3967
4329
  } as any);
3968
4330
  (method as any).device = device;
4331
+ (method as any).protocolV2ExpectedPath = 'usb-path';
3969
4332
 
3970
4333
  try {
3971
4334
  await (method as any).reconnectProtocolV2Device();
@@ -3977,6 +4340,184 @@ describe('Protocol V2 firmware update targets', () => {
3977
4340
  expect(initialize).not.toHaveBeenCalled();
3978
4341
  });
3979
4342
 
4343
+ test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
4344
+ const method = new FirmwareUpdateV4({
4345
+ id: 1,
4346
+ payload: {
4347
+ method: 'firmwareUpdateV4',
4348
+ },
4349
+ });
4350
+ const commands = { disposed: true, mainId: '' };
4351
+ const updateFromCache = jest.fn();
4352
+ const device = stubDevice({
4353
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4354
+ deviceConnector: {
4355
+ enumerate: jest.fn().mockResolvedValue({
4356
+ descriptors: [
4357
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4358
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4359
+ ],
4360
+ }),
4361
+ },
4362
+ updateFromCache,
4363
+ hasDeviceAcquire: jest.fn(() => false),
4364
+ acquire: jest.fn().mockResolvedValue(undefined),
4365
+ commands,
4366
+ getCommands: () => commands,
4367
+ mainId: 'old-usb-path',
4368
+ });
4369
+ const otherDevice = {
4370
+ getConnectId: () => 'usb-a',
4371
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4372
+ };
4373
+ const expectedDevice = {
4374
+ getConnectId: () => 'usb-b',
4375
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4376
+ };
4377
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4378
+ devices: {},
4379
+ deviceList: [otherDevice, expectedDevice],
4380
+ } as any);
4381
+ (method as any).device = device;
4382
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4383
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4384
+
4385
+ try {
4386
+ await (method as any).reconnectProtocolV2Device();
4387
+ } finally {
4388
+ getDevices.mockRestore();
4389
+ }
4390
+
4391
+ expect(updateFromCache).toHaveBeenCalledWith(expectedDevice);
4392
+ });
4393
+
4394
+ test('rejects ambiguous Protocol V2 USB reconnect candidates without an identity match', async () => {
4395
+ const method = new FirmwareUpdateV4({
4396
+ id: 1,
4397
+ payload: {
4398
+ method: 'firmwareUpdateV4',
4399
+ },
4400
+ });
4401
+ const updateFromCache = jest.fn();
4402
+ const device = stubDevice({
4403
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4404
+ deviceConnector: {
4405
+ enumerate: jest.fn().mockResolvedValue({
4406
+ descriptors: [
4407
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4408
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4409
+ ],
4410
+ }),
4411
+ },
4412
+ updateFromCache,
4413
+ });
4414
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4415
+ devices: {},
4416
+ deviceList: [
4417
+ {
4418
+ getConnectId: () => 'usb-a',
4419
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4420
+ },
4421
+ {
4422
+ getConnectId: () => 'usb-b',
4423
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4424
+ },
4425
+ ],
4426
+ } as any);
4427
+ (method as any).device = device;
4428
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-C';
4429
+
4430
+ try {
4431
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4432
+ } finally {
4433
+ getDevices.mockRestore();
4434
+ }
4435
+
4436
+ expect(updateFromCache).not.toHaveBeenCalled();
4437
+ });
4438
+
4439
+ test('allows a Protocol V2 USB reconnect candidate without a serial only when its path matches', async () => {
4440
+ const method = new FirmwareUpdateV4({
4441
+ id: 1,
4442
+ payload: {
4443
+ method: 'firmwareUpdateV4',
4444
+ },
4445
+ });
4446
+ const commands = { disposed: true, mainId: '' };
4447
+ const updateFromCache = jest.fn();
4448
+ const device = stubDevice({
4449
+ originalDescriptor: { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4450
+ deviceConnector: {
4451
+ enumerate: jest.fn().mockResolvedValue({
4452
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4453
+ }),
4454
+ },
4455
+ updateFromCache,
4456
+ hasDeviceAcquire: jest.fn(() => false),
4457
+ acquire: jest.fn().mockResolvedValue(undefined),
4458
+ commands,
4459
+ getCommands: () => commands,
4460
+ mainId: 'old-usb-path',
4461
+ });
4462
+ const candidate = {
4463
+ getConnectId: () => 'usb-a',
4464
+ getCurrentSerialNo: () => '',
4465
+ };
4466
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4467
+ devices: {},
4468
+ deviceList: [candidate],
4469
+ } as any);
4470
+ (method as any).device = device;
4471
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-A';
4472
+ (method as any).protocolV2ExpectedPath = 'usb-a';
4473
+
4474
+ try {
4475
+ await (method as any).reconnectProtocolV2Device();
4476
+ } finally {
4477
+ getDevices.mockRestore();
4478
+ }
4479
+
4480
+ expect(updateFromCache).toHaveBeenCalledWith(candidate);
4481
+ });
4482
+
4483
+ test('rejects a single Protocol V2 USB reconnect candidate with a different physical serial', async () => {
4484
+ const method = new FirmwareUpdateV4({
4485
+ id: 1,
4486
+ payload: {
4487
+ method: 'firmwareUpdateV4',
4488
+ },
4489
+ });
4490
+ const updateFromCache = jest.fn();
4491
+ const device = stubDevice({
4492
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4493
+ deviceConnector: {
4494
+ enumerate: jest.fn().mockResolvedValue({
4495
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4496
+ }),
4497
+ },
4498
+ updateFromCache,
4499
+ });
4500
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4501
+ devices: {},
4502
+ deviceList: [
4503
+ {
4504
+ getConnectId: () => 'usb-a',
4505
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4506
+ },
4507
+ ],
4508
+ } as any);
4509
+ (method as any).device = device;
4510
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4511
+
4512
+ try {
4513
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4514
+ } finally {
4515
+ getDevices.mockRestore();
4516
+ }
4517
+
4518
+ expect(updateFromCache).not.toHaveBeenCalled();
4519
+ });
4520
+
3980
4521
  test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
3981
4522
  const method = new FirmwareUpdateV4({
3982
4523
  id: 1,
@@ -4018,6 +4559,7 @@ describe('Protocol V2 firmware update targets', () => {
4018
4559
  deviceList: [{ getConnectId: () => 'usb-path' }],
4019
4560
  } as any);
4020
4561
  (method as any).device = device;
4562
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4021
4563
 
4022
4564
  try {
4023
4565
  await (method as any).reconnectProtocolV2Device();
@@ -4048,6 +4590,7 @@ describe('Protocol V2 firmware update targets', () => {
4048
4590
  (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
4049
4591
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4050
4592
  (method as any).device = stubDevice({
4593
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4051
4594
  getCommands: () => ({ typedCall }),
4052
4595
  probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4053
4596
  deviceId: 'changed-device',
@@ -4071,7 +4614,9 @@ describe('Protocol V2 firmware update targets', () => {
4071
4614
  });
4072
4615
  method.postTipMessage = jest.fn();
4073
4616
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4074
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4617
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4618
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4619
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
4075
4620
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4076
4621
  message: 'Device rebooted successfully',
4077
4622
  });
@@ -4080,9 +4625,29 @@ describe('Protocol V2 firmware update targets', () => {
4080
4625
 
4081
4626
  expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4082
4627
  expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4628
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4083
4629
  expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4084
4630
  });
4085
4631
 
4632
+ test('skips a second Normal reboot after Pro2 already returned to App mode', async () => {
4633
+ const method = new FirmwareUpdateV4({
4634
+ id: 1,
4635
+ payload: {
4636
+ method: 'firmwareUpdateV4',
4637
+ },
4638
+ });
4639
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4640
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4641
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4642
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4643
+ (method as any).protocolV2Reboot = jest.fn();
4644
+
4645
+ await (method as any).exitProtocolV2BootloaderToNormal();
4646
+
4647
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4648
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4649
+ });
4650
+
4086
4651
  test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4087
4652
  const method = new FirmwareUpdateV4({
4088
4653
  id: 1,
@@ -4125,29 +4690,50 @@ describe('Protocol V2 firmware update targets', () => {
4125
4690
  });
4126
4691
  });
4127
4692
 
4128
- test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4693
+ test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
4129
4694
  const method = new FirmwareUpdateV4({
4130
4695
  id: 1,
4131
4696
  payload: {
4132
4697
  method: 'firmwareUpdateV4',
4133
4698
  },
4134
4699
  });
4135
- const typedCall = jest
4136
- .fn()
4137
- .mockRejectedValue(
4138
- Object.assign(
4139
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
- { name: 'NotFoundError' }
4141
- )
4142
- );
4700
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
4143
4701
 
4144
4702
  (method as any).device = stubDevice({
4145
4703
  getCommands: () => ({ typedCall }),
4146
4704
  });
4147
- method.postTipMessage = jest.fn();
4148
- method.postProgressMessage = jest.fn();
4149
4705
 
4150
- await expect(
4706
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
4707
+ message: 'Device rebooted successfully',
4708
+ });
4709
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4710
+ DeviceRebootType.Bootloader
4711
+ );
4712
+ });
4713
+
4714
+ test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4715
+ const method = new FirmwareUpdateV4({
4716
+ id: 1,
4717
+ payload: {
4718
+ method: 'firmwareUpdateV4',
4719
+ },
4720
+ });
4721
+ const typedCall = jest
4722
+ .fn()
4723
+ .mockRejectedValue(
4724
+ new DOMException(
4725
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4726
+ 'NotFoundError'
4727
+ )
4728
+ );
4729
+
4730
+ (method as any).device = stubDevice({
4731
+ getCommands: () => ({ typedCall }),
4732
+ });
4733
+ method.postTipMessage = jest.fn();
4734
+ method.postProgressMessage = jest.fn();
4735
+
4736
+ await expect(
4151
4737
  (method as any).protocolV2StartFirmwareUpdate({
4152
4738
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
4153
4739
  })
@@ -4215,7 +4801,7 @@ describe('Protocol V2 firmware update targets', () => {
4215
4801
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4216
4802
  ]);
4217
4803
 
4218
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4804
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4219
4805
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4220
4806
  'DeviceFirmwareUpdateStatusGet',
4221
4807
  'DeviceFirmwareUpdateStatusGet',
@@ -4253,6 +4839,46 @@ describe('Protocol V2 firmware update targets', () => {
4253
4839
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4254
4840
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4255
4841
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4842
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4843
+ });
4844
+
4845
+ test('accepts an empty firmware status dump only after confirming normal mode', async () => {
4846
+ const method = new FirmwareUpdateV4({
4847
+ id: 1,
4848
+ payload: {
4849
+ method: 'firmwareUpdateV4',
4850
+ },
4851
+ });
4852
+ const typedCall = jest.fn().mockResolvedValue({
4853
+ type: 'DeviceFirmwareUpdateStatus',
4854
+ message: { records: [] },
4855
+ });
4856
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4857
+ let now = 0;
4858
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
4859
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4860
+ now += 31 * 1000;
4861
+ callback();
4862
+ return 0 as any;
4863
+ }) as typeof setTimeout);
4864
+
4865
+ (method as any).device = stubDevice({
4866
+ getCommands: () => ({ typedCall }),
4867
+ });
4868
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4869
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4870
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4871
+ method.postProgressMessage = jest.fn();
4872
+
4873
+ await expect(
4874
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
4875
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4876
+ ])
4877
+ ).resolves.toBeUndefined();
4878
+
4879
+ expect(typedCall).toHaveBeenCalledTimes(1);
4880
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4881
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4256
4882
  });
4257
4883
 
4258
4884
  test.each([
@@ -4318,94 +4944,123 @@ describe('Protocol V2 firmware update targets', () => {
4318
4944
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4319
4945
  });
4320
4946
 
4321
- test('rejects a finished target whose payload version conflicts with the plan', () => {
4947
+ test('polls target status after update ACK and finishes when all targets complete', async () => {
4322
4948
  const method = new FirmwareUpdateV4({
4323
4949
  id: 1,
4324
4950
  payload: {
4325
4951
  method: 'firmwareUpdateV4',
4326
- platform: 'desktop',
4327
- expectedTargetVersions: {
4328
- app_v1: '2.0.0',
4329
- },
4330
4952
  },
4331
4953
  });
4332
- method.init();
4954
+ const typedCall = jest.fn().mockResolvedValue({
4955
+ type: 'DeviceFirmwareUpdateStatus',
4956
+ message: {
4957
+ records: [
4958
+ { target_id: 3, status: 2 },
4959
+ { target_id: 4, status: 2 },
4960
+ ],
4961
+ },
4962
+ });
4963
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4964
+
4965
+ (method as any).device = stubDevice({
4966
+ getCommands: () => ({ typedCall }),
4967
+ });
4968
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4969
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4333
4970
  method.postProgressMessage = jest.fn();
4334
4971
 
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');
4972
+ await expect(
4973
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
4974
+ { target_id: 3, path: 'vol1:bootloader.bin' },
4975
+ { target_id: 4, path: 'vol1:application_p1.bin' },
4976
+ ])
4977
+ ).resolves.toBeUndefined();
4978
+
4979
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4980
+ expect(typedCall).toHaveBeenCalledWith(
4981
+ 'DeviceFirmwareUpdateStatusGet',
4982
+ 'DeviceFirmwareUpdateStatus',
4983
+ expect.anything(),
4984
+ expect.anything()
4985
+ );
4986
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4341
4987
  });
4342
4988
 
4343
- test('does not fail a completed multi-app install when final app versions are unobservable', () => {
4989
+ test('fails clearly when a requested target stays absent from the full status dump', async () => {
4344
4990
  const method = new FirmwareUpdateV4({
4345
4991
  id: 1,
4346
4992
  payload: {
4347
4993
  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
4994
  },
4355
4995
  });
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
- },
4996
+ const typedCall = jest.fn().mockResolvedValue({
4997
+ type: 'DeviceFirmwareUpdateStatus',
4998
+ message: {
4999
+ records: [{ target_id: 4, status: 2 }],
4378
5000
  },
4379
5001
  });
4380
- method.init();
4381
- (method as any).protocolV2LatestFinalFeatures = {
4382
- major_version: 3,
4383
- minor_version: 0,
4384
- patch_version: 0,
4385
- };
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);
4386
5009
 
4387
- expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
4388
- 'has no observable final version after status fallback'
4389
- );
5010
+ (method as any).device = stubDevice({
5011
+ getCommands: () => ({ typedCall }),
5012
+ });
5013
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
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
+ ).rejects.toMatchObject({
5023
+ errorCode: HardwareErrorCode.FirmwareError,
5024
+ message: expect.stringContaining('targetIds=3'),
5025
+ });
5026
+ expect(typedCall).toHaveBeenCalledTimes(2);
4390
5027
  });
4391
5028
 
4392
- test('polls target status after update ACK and finishes when all targets complete', async () => {
5029
+ test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
4393
5030
  const method = new FirmwareUpdateV4({
4394
5031
  id: 1,
4395
5032
  payload: {
4396
5033
  method: 'firmwareUpdateV4',
4397
5034
  },
4398
5035
  });
4399
- const typedCall = jest.fn().mockResolvedValue({
4400
- type: 'DeviceFirmwareUpdateStatus',
4401
- message: {
4402
- records: [
4403
- { target_id: 3, status: 2 },
4404
- { target_id: 4, status: 2 },
4405
- ],
4406
- },
4407
- });
5036
+ const typedCall = jest
5037
+ .fn()
5038
+ .mockResolvedValueOnce({
5039
+ type: 'DeviceFirmwareUpdateStatus',
5040
+ message: { records: [{ target_id: 4, status: 2 }] },
5041
+ })
5042
+ .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
5043
+ .mockResolvedValueOnce({
5044
+ type: 'DeviceFirmwareUpdateStatus',
5045
+ message: { records: [{ target_id: 4, status: 2 }] },
5046
+ })
5047
+ .mockResolvedValueOnce({
5048
+ type: 'DeviceFirmwareUpdateStatus',
5049
+ message: {
5050
+ records: [
5051
+ { target_id: 3, status: 2 },
5052
+ { target_id: 4, status: 2 },
5053
+ ],
5054
+ },
5055
+ });
4408
5056
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5057
+ let now = 0;
5058
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5059
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5060
+ now += 31 * 1000;
5061
+ callback();
5062
+ return 0 as any;
5063
+ }) as typeof setTimeout);
4409
5064
 
4410
5065
  (method as any).device = stubDevice({
4411
5066
  getCommands: () => ({ typedCall }),
@@ -4416,19 +5071,64 @@ describe('Protocol V2 firmware update targets', () => {
4416
5071
 
4417
5072
  await expect(
4418
5073
  (method as any).waitForProtocolV2FirmwareUpdateComplete([
4419
- { target_id: 3, path: 'vol1:bootloader.bin' },
4420
- { target_id: 4, path: 'vol1:application_p1.bin' },
5074
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
5075
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4421
5076
  ])
4422
5077
  ).resolves.toBeUndefined();
5078
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5079
+ expect(typedCall).toHaveBeenCalledTimes(4);
5080
+ });
4423
5081
 
4424
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4425
- expect(typedCall).toHaveBeenCalledWith(
4426
- 'DeviceFirmwareUpdateStatusGet',
4427
- 'DeviceFirmwareUpdateStatus',
4428
- expect.anything(),
4429
- expect.anything()
5082
+ test('uses an eight-minute Pro2 install status window', async () => {
5083
+ const method = new FirmwareUpdateV4({
5084
+ id: 1,
5085
+ payload: {
5086
+ method: 'firmwareUpdateV4',
5087
+ },
5088
+ });
5089
+ jest
5090
+ .spyOn(Date, 'now')
5091
+ .mockReturnValueOnce(0)
5092
+ .mockReturnValue(8 * 60 * 1000);
5093
+
5094
+ await expect(
5095
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5096
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5097
+ ])
5098
+ ).rejects.toThrow('within 480s');
5099
+ });
5100
+
5101
+ test('uses a 90-second Pro2 bootloader reconnect window', async () => {
5102
+ const method = new FirmwareUpdateV4({
5103
+ id: 1,
5104
+ payload: {
5105
+ method: 'firmwareUpdateV4',
5106
+ },
5107
+ });
5108
+ jest
5109
+ .spyOn(Date, 'now')
5110
+ .mockReturnValueOnce(0)
5111
+ .mockReturnValue(90 * 1000);
5112
+
5113
+ await expect((method as any).waitForProtocolV2BootloaderMode()).rejects.toThrow('within 90s');
5114
+ });
5115
+
5116
+ test('uses a three-minute Pro2 final reconnect window', async () => {
5117
+ const method = new FirmwareUpdateV4({
5118
+ id: 1,
5119
+ payload: {
5120
+ method: 'firmwareUpdateV4',
5121
+ },
5122
+ });
5123
+ const waitForProtocolV2ReconnectAndFeatures = jest
5124
+ .fn()
5125
+ .mockRejectedValue(new Error('stop after capturing timeout'));
5126
+ (method as any).waitForProtocolV2ReconnectAndFeatures = waitForProtocolV2ReconnectAndFeatures;
5127
+
5128
+ await expect((method as any).waitForProtocolV2FinalFeatures()).rejects.toThrow(
5129
+ 'stop after capturing timeout'
4430
5130
  );
4431
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5131
+ expect(waitForProtocolV2ReconnectAndFeatures).toHaveBeenCalledWith(3 * 60 * 1000);
4432
5132
  });
4433
5133
 
4434
5134
  test('uses SDK decoded enum names for Protocol V2 install polling', () => {
@@ -4465,7 +5165,7 @@ describe('Protocol V2 firmware update targets', () => {
4465
5165
  ).toBe(true);
4466
5166
 
4467
5167
  (method.postProgressMessage as jest.Mock).mockClear();
4468
- expect(() =>
5168
+ expect(
4469
5169
  (method as any).assertProtocolV2TargetStatus(
4470
5170
  [
4471
5171
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4473,8 +5173,8 @@ describe('Protocol V2 firmware update targets', () => {
4473
5173
  ],
4474
5174
  new Set([4, 10])
4475
5175
  )
4476
- ).toThrow('Protocol V2 install status conflicts with target 4');
4477
- expect(method.postProgressMessage).not.toHaveBeenCalled();
5176
+ ).toBe(false);
5177
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4478
5178
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4479
5179
 
4480
5180
  expect(
@@ -4563,7 +5263,7 @@ describe('Protocol V2 firmware update targets', () => {
4563
5263
  path: true,
4564
5264
  },
4565
5265
  },
4566
- { timeoutMs: 5000 }
5266
+ { timeoutMs: 15000 }
4567
5267
  );
4568
5268
  });
4569
5269
 
@@ -4578,14 +5278,10 @@ describe('Protocol V2 firmware update targets', () => {
4578
5278
  const writtenPaths: string[] = [];
4579
5279
  method.postTipMessage = jest.fn();
4580
5280
  method.postProgressMessage = jest.fn();
4581
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
5281
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4582
5282
  writtenPaths.push(params.filePath);
4583
- return Number(params.processedSize ?? 0) + Number(params.source.size);
5283
+ return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4584
5284
  });
4585
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4586
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4587
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
4588
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4589
5285
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4590
5286
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4591
5287
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4625,12 +5321,10 @@ describe('Protocol V2 firmware update targets', () => {
4625
5321
  'vol0:/coprocessor.bin',
4626
5322
  1
4627
5323
  );
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, {
5324
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
5325
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4633
5326
  targets: [
5327
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
4634
5328
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4635
5329
  { target_id: 7, path: 'vol0:/se01.bin' },
4636
5330
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4640,7 +5334,7 @@ describe('Protocol V2 firmware update targets', () => {
4640
5334
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4641
5335
  });
4642
5336
 
4643
- test('announces separate resource and component transfer phases', async () => {
5337
+ test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4644
5338
  const method = new FirmwareUpdateV4({
4645
5339
  id: 1,
4646
5340
  payload: {
@@ -4650,14 +5344,11 @@ describe('Protocol V2 firmware update targets', () => {
4650
5344
 
4651
5345
  method.postTipMessage = jest.fn();
4652
5346
  method.postProgressMessage = jest.fn();
4653
- (method as any).protocolV2SourceUpdateProcess = jest
5347
+ (method as any).protocolV2CommonUpdateProcess = jest
4654
5348
  .fn()
4655
5349
  .mockImplementation(params =>
4656
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5350
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4657
5351
  );
4658
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4659
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4660
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4661
5352
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4662
5353
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4663
5354
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4677,22 +5368,21 @@ describe('Protocol V2 firmware update targets', () => {
4677
5368
  {
4678
5369
  fileName: 'application_p1.bin',
4679
5370
  binary: new Uint8Array([3]).buffer,
4680
- targetId: 4,
5371
+ targetId: 3,
4681
5372
  },
4682
5373
  ],
4683
5374
  });
4684
5375
 
4685
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
5376
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4686
5377
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
4687
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5378
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5379
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4690
5380
  1,
4691
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
5381
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
4692
5382
  );
4693
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5383
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4694
5384
  2,
4695
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
5385
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
4696
5386
  );
4697
5387
  });
4698
5388
 
@@ -4706,8 +5396,7 @@ describe('Protocol V2 firmware update targets', () => {
4706
5396
 
4707
5397
  method.postTipMessage = jest.fn();
4708
5398
  method.postProgressMessage = jest.fn();
4709
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5399
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4711
5400
  (method as any).device = stubDevice({
4712
5401
  getCommands: () => ({
4713
5402
  typedCall: jest.fn().mockResolvedValue({
@@ -4753,14 +5442,11 @@ describe('Protocol V2 firmware update targets', () => {
4753
5442
 
4754
5443
  method.postTipMessage = jest.fn();
4755
5444
  method.postProgressMessage = jest.fn();
4756
- (method as any).protocolV2SourceUpdateProcess = jest
5445
+ (method as any).protocolV2CommonUpdateProcess = jest
4757
5446
  .fn()
4758
5447
  .mockImplementation(params =>
4759
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5448
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4760
5449
  );
4761
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4762
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4763
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4764
5450
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4765
5451
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4766
5452
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4975,33 +5661,42 @@ describe('Protocol V2 firmware update targets', () => {
4975
5661
  getFirmwareLatestReleaseSpy.mockRestore();
4976
5662
  });
4977
5663
 
4978
- test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5664
+ test('does not download Pro2 firmware components that App did not select', async () => {
4979
5665
  const method = new FirmwareUpdateV4({
4980
5666
  id: 1,
4981
5667
  payload: {
4982
5668
  method: 'firmwareUpdateV4',
4983
5669
  platform: 'web',
4984
- targetsToUpdate: ['resource'],
4985
5670
  },
4986
5671
  });
4987
5672
  method.init();
4988
5673
 
5674
+ const getSysResourceBinarySpy = jest
5675
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5676
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
4989
5677
  const getFirmwareLatestReleaseSpy = jest
4990
5678
  .spyOn(DataManager, 'getFirmwareLatestRelease')
4991
5679
  .mockReturnValue({
4992
5680
  required: false,
4993
5681
  version: [1, 0, 0],
4994
- url: '',
4995
- resourceBundles: [
4996
- {
4997
- name: 'images',
4998
- url: 'https://example.com/images.okpkg',
4999
- devicePath: 'VOL0:/resource/images/images.okpkg',
5000
- version: [2, 0, 0],
5001
- payloadHash: 'ab'.repeat(64),
5002
- headerHash: 'cd'.repeat(64),
5682
+ url: 'https://example.com/applicationP1.pp.bin',
5683
+ bootloaderVersion: [1, 0, 0],
5684
+ upgradeType: 'payload-package-set',
5685
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5686
+ components: {
5687
+ bootloader: {
5688
+ target: 'BOOTLOADER',
5689
+ url: 'https://example.com/bootloader.pp.bin',
5003
5690
  },
5004
- ],
5691
+ applicationP1: {
5692
+ target: 'APPLICATION_P1',
5693
+ url: 'https://example.com/applicationP1.pp.bin',
5694
+ },
5695
+ applicationP2: {
5696
+ target: 'APPLICATION_P2',
5697
+ url: 'https://example.com/applicationP2.pp.bin',
5698
+ },
5699
+ },
5005
5700
  fingerprint: '',
5006
5701
  changelog: {
5007
5702
  'zh-CN': '',
@@ -5009,356 +5704,25 @@ describe('Protocol V2 firmware update targets', () => {
5009
5704
  },
5010
5705
  });
5011
5706
 
5012
- const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5707
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5013
5708
  deviceType: 'pro2',
5014
5709
  firmwareVersion: '1.0.0',
5710
+ bootloaderVersion: '1.0.0',
5015
5711
  capabilities: [],
5016
5712
  });
5017
5713
 
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);
5714
+ expect(remoteBinaries).toEqual({
5715
+ bootloaderBinary: null,
5716
+ fwBinaryMap: [],
5717
+ installItems: [],
5718
+ });
5719
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5030
5720
 
5721
+ getSysResourceBinarySpy.mockRestore();
5031
5722
  getFirmwareLatestReleaseSpy.mockRestore();
5032
5723
  });
5033
5724
 
5034
- test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5035
- const method = new FirmwareUpdateV4({
5036
- id: 1,
5037
- payload: {
5038
- method: 'firmwareUpdateV4',
5039
- platform: 'web',
5040
- targetsToUpdate: ['resource'],
5041
- },
5042
- });
5043
- method.init();
5044
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5045
-
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': '',
5064
- },
5065
- });
5066
-
5067
- (method as any).device = stubDevice({
5068
- originalDescriptor: { protocolType: 'V2' },
5069
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5070
- });
5071
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
- bootloaderBinary: null,
5073
- fwBinaryMap: [],
5074
- });
5075
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5076
- method.postTipMessage = jest.fn();
5077
-
5078
- await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5081
-
5082
- getSysResourceBinarySpy.mockRestore();
5083
- getFirmwareLatestReleaseSpy.mockRestore();
5084
- });
5085
-
5086
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5087
- const method = new FirmwareUpdateV4({
5088
- id: 1,
5089
- payload: {
5090
- method: 'firmwareUpdateV4',
5091
- },
5092
- });
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
- });
5127
-
5128
- await expect(
5129
- (method as any).prepareProtocolV2ResourceSources('universal', {
5130
- deviceType: 'pro2',
5131
- })
5132
- ).rejects.toThrow('resourceBundles[].devicePath');
5133
- expect(openPreparedSource).not.toHaveBeenCalled();
5134
-
5135
- getFirmwareLatestReleaseSpy.mockRestore();
5136
- });
5137
-
5138
- test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5139
- const method = new FirmwareUpdateV4({
5140
- id: 1,
5141
- payload: {
5142
- method: 'firmwareUpdateV4',
5143
- platform: 'web',
5144
- targetsToUpdate: ['resource'],
5145
- },
5146
- });
5147
- method.init();
5148
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5149
-
5150
- const order: string[] = [];
5151
- const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5152
- const getSysResourceBinarySpy = jest
5153
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5154
- .mockImplementation(() => {
5155
- order.push('download');
5156
- return Promise.resolve({ binary: resourceBinary });
5157
- });
5158
- const getFirmwareLatestReleaseSpy = jest
5159
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5160
- .mockReturnValue({
5161
- required: false,
5162
- version: [1, 0, 0],
5163
- url: '',
5164
- resourceBundles: [
5165
- {
5166
- name: 'images',
5167
- url: 'https://example.com/images.okpkg',
5168
- devicePath: ' VOL0:/resource/images/images.okpkg ',
5169
- },
5170
- ],
5171
- fingerprint: '',
5172
- changelog: {
5173
- 'zh-CN': '',
5174
- 'en-US': '',
5175
- },
5176
- });
5177
-
5178
- (method as any).device = stubDevice({
5179
- originalDescriptor: { protocolType: 'V2' },
5180
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5181
- });
5182
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5183
- bootloaderBinary: null,
5184
- fwBinaryMap: [],
5185
- });
5186
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
5187
- order.push('bootloader');
5188
- return Promise.resolve(true);
5189
- });
5190
- const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5191
- (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5192
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5193
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5194
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5195
- bootloaderVersion: '1.0.0',
5196
- bleVersion: '0.0.0',
5197
- firmwareVersion: '1.0.0',
5198
- });
5199
- method.postTipMessage = jest.fn();
5200
-
5201
- await method.run();
5202
-
5203
- expect(order).toEqual(['download', 'bootloader']);
5204
- expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5205
- bootloaderBinary: null,
5206
- fwBinaryMap: [],
5207
- resourceBundles: [
5208
- {
5209
- name: 'images',
5210
- binary: resourceBinary,
5211
- devicePath: 'vol0:/resource/images/images.okpkg',
5212
- url: 'https://example.com/images.okpkg',
5213
- },
5214
- ],
5215
- });
5216
-
5217
- executeProtocolV2UpdateSpy.mockRestore();
5218
- getSysResourceBinarySpy.mockRestore();
5219
- getFirmwareLatestReleaseSpy.mockRestore();
5220
- });
5221
-
5222
- test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5223
- const method = new FirmwareUpdateV4({
5224
- id: 1,
5225
- payload: {
5226
- method: 'firmwareUpdateV4',
5227
- },
5228
- });
5229
- method.init();
5230
- const getSysResourceBinarySpy = jest
5231
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5232
- .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5233
-
5234
- await expect(
5235
- (method as any).materializeProtocolV2ResourceBundles([
5236
- {
5237
- name: 'images',
5238
- binary: new ArrayBuffer(0),
5239
- devicePath: 'vol0:/resource/images/images.okpkg',
5240
- url: 'https://example.com/images.okpkg',
5241
- },
5242
- ])
5243
- ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5244
-
5245
- getSysResourceBinarySpy.mockRestore();
5246
- });
5247
-
5248
- test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5249
- const method = new FirmwareUpdateV4({
5250
- id: 1,
5251
- payload: {
5252
- method: 'firmwareUpdateV4',
5253
- platform: 'web',
5254
- targetsToUpdate: ['resource'],
5255
- },
5256
- });
5257
- method.init();
5258
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5259
-
5260
- const getSysResourceBinarySpy = jest
5261
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5262
- .mockResolvedValue({ binary: new ArrayBuffer(0) });
5263
- const getFirmwareLatestReleaseSpy = jest
5264
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5265
- .mockReturnValue({
5266
- required: false,
5267
- version: [1, 0, 0],
5268
- url: '',
5269
- resourceBundles: [
5270
- {
5271
- name: 'images',
5272
- url: 'https://example.com/images.okpkg',
5273
- devicePath: 'vol0:/resource/images/images.okpkg',
5274
- },
5275
- ],
5276
- fingerprint: '',
5277
- changelog: {
5278
- 'zh-CN': '',
5279
- 'en-US': '',
5280
- },
5281
- });
5282
- (method as any).device = stubDevice({
5283
- originalDescriptor: { protocolType: 'V2' },
5284
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5285
- });
5286
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5287
- bootloaderBinary: null,
5288
- fwBinaryMap: [],
5289
- });
5290
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5291
- method.postTipMessage = jest.fn();
5292
-
5293
- await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5294
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5295
-
5296
- getSysResourceBinarySpy.mockRestore();
5297
- getFirmwareLatestReleaseSpy.mockRestore();
5298
- });
5299
-
5300
- test('does not download Pro2 firmware components that App did not select', async () => {
5301
- const method = new FirmwareUpdateV4({
5302
- id: 1,
5303
- payload: {
5304
- method: 'firmwareUpdateV4',
5305
- platform: 'web',
5306
- },
5307
- });
5308
- method.init();
5309
-
5310
- const getSysResourceBinarySpy = jest
5311
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5312
- .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5313
- const getFirmwareLatestReleaseSpy = jest
5314
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5315
- .mockReturnValue({
5316
- required: false,
5317
- version: [1, 0, 0],
5318
- url: 'https://example.com/applicationP1.pp.bin',
5319
- bootloaderVersion: [1, 0, 0],
5320
- upgradeType: 'payload-package-set',
5321
- installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5322
- components: {
5323
- bootloader: {
5324
- target: 'BOOTLOADER',
5325
- url: 'https://example.com/bootloader.pp.bin',
5326
- },
5327
- applicationP1: {
5328
- target: 'APPLICATION_P1',
5329
- url: 'https://example.com/applicationP1.pp.bin',
5330
- },
5331
- applicationP2: {
5332
- target: 'APPLICATION_P2',
5333
- url: 'https://example.com/applicationP2.pp.bin',
5334
- },
5335
- },
5336
- fingerprint: '',
5337
- changelog: {
5338
- 'zh-CN': '',
5339
- 'en-US': '',
5340
- },
5341
- });
5342
-
5343
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5344
- deviceType: 'pro2',
5345
- firmwareVersion: '1.0.0',
5346
- bootloaderVersion: '1.0.0',
5347
- capabilities: [],
5348
- });
5349
-
5350
- expect(remoteBinaries).toEqual({
5351
- bootloaderBinary: null,
5352
- fwBinaryMap: [],
5353
- installItems: [],
5354
- });
5355
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5356
-
5357
- getSysResourceBinarySpy.mockRestore();
5358
- getFirmwareLatestReleaseSpy.mockRestore();
5359
- });
5360
-
5361
- test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5725
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5362
5726
  const method = new FirmwareUpdateV4({
5363
5727
  id: 1,
5364
5728
  payload: {
@@ -5397,6 +5761,7 @@ describe('Protocol V2 firmware update targets', () => {
5397
5761
 
5398
5762
  await method.run();
5399
5763
 
5764
+ expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5400
5765
  expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5401
5766
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5402
5767
  bootloaderBinary: remoteBinaries.bootloaderBinary,
@@ -5404,6 +5769,36 @@ describe('Protocol V2 firmware update targets', () => {
5404
5769
  });
5405
5770
  });
5406
5771
 
5772
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5773
+ const method = new FirmwareUpdateV4({
5774
+ id: 1,
5775
+ payload: {
5776
+ method: 'firmwareUpdateV4',
5777
+ platform: 'web',
5778
+ },
5779
+ });
5780
+ method.init();
5781
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5782
+ (method as any).device = stubDevice({
5783
+ originalDescriptor: { protocolType: 'V2' },
5784
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5785
+ });
5786
+ forceReloadDataSpy.mockRejectedValue(
5787
+ ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
5788
+ );
5789
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5790
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5791
+ method.postTipMessage = jest.fn();
5792
+
5793
+ await expect(method.run()).rejects.toMatchObject({
5794
+ errorCode: HardwareErrorCode.NetworkError,
5795
+ message: 'latest config unavailable',
5796
+ });
5797
+
5798
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5799
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5800
+ });
5801
+
5407
5802
  test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
5408
5803
  const method = new FirmwareUpdateV4({
5409
5804
  id: 1,
@@ -5440,14 +5835,14 @@ describe('Protocol V2 firmware update targets', () => {
5440
5835
  });
5441
5836
  });
5442
5837
 
5443
- test('treats manual resource bundles as explicit payload without remote firmware auto-fill', async () => {
5838
+ test('treats manual resource files as explicit payload without remote firmware auto-fill', async () => {
5444
5839
  const resourceBundle = new Uint8Array([1, 2, 3]).buffer;
5445
5840
  const method = new FirmwareUpdateV4({
5446
5841
  id: 1,
5447
5842
  payload: {
5448
5843
  method: 'firmwareUpdateV4',
5449
5844
  platform: 'web',
5450
- resourceBundleFiles: [
5845
+ resourceFiles: [
5451
5846
  {
5452
5847
  binary: resourceBundle,
5453
5848
  devicePath: ' VOL0:/resource/images/images.okpkg ',
@@ -5495,7 +5890,7 @@ describe('Protocol V2 firmware update targets', () => {
5495
5890
  payload: {
5496
5891
  method: 'firmwareUpdateV4',
5497
5892
  platform: 'web',
5498
- resourceBundleFiles: [
5893
+ resourceFiles: [
5499
5894
  {
5500
5895
  binary: new Uint8Array([1, 2, 3]).buffer,
5501
5896
  devicePath: 'vol2:/resource/images/images.okpkg',
@@ -5514,7 +5909,7 @@ describe('Protocol V2 firmware update targets', () => {
5514
5909
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
5515
5910
  method.postTipMessage = jest.fn();
5516
5911
 
5517
- await expect(method.run()).rejects.toThrow('resourceBundleFiles[0].devicePath');
5912
+ await expect(method.run()).rejects.toThrow('resourceFiles[0].devicePath');
5518
5913
  expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5519
5914
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5520
5915
  });
@@ -5529,10 +5924,7 @@ describe('Protocol V2 firmware update targets', () => {
5529
5924
 
5530
5925
  method.postTipMessage = jest.fn();
5531
5926
  method.postProgressMessage = jest.fn();
5532
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5533
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5534
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5535
- (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5927
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5536
5928
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5537
5929
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5538
5930
 
@@ -5548,7 +5940,7 @@ describe('Protocol V2 firmware update targets', () => {
5548
5940
  fwBinaryMap: [],
5549
5941
  });
5550
5942
 
5551
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5943
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5552
5944
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5553
5945
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5554
5946
  });
@@ -5576,22 +5968,17 @@ describe('Protocol V2 firmware update targets', () => {
5576
5968
 
5577
5969
  (method as any).device = stubDevice({
5578
5970
  getCommands: () => ({ typedCall }),
5971
+ getCurrentDeviceType: () => 'pro2',
5972
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
5579
5973
  });
5580
- method.postProgressMessage = jest.fn();
5974
+ method.postMessage = jest.fn();
5581
5975
 
5582
- const source = await openFirmwareByteSource({
5583
- binary: new Uint8Array(4097).buffer,
5976
+ await (method as any).protocolV2CommonUpdateProcess({
5977
+ payload: new Uint8Array(4097).buffer,
5978
+ filePath: 'vol1:firmware.bin',
5979
+ processedSize: 0,
5980
+ totalSize: 4097,
5584
5981
  });
5585
- try {
5586
- await (method as any).protocolV2SourceUpdateProcess({
5587
- source,
5588
- filePath: 'vol1:firmware.bin',
5589
- processedSize: 0,
5590
- totalSize: 4097,
5591
- });
5592
- } finally {
5593
- await source?.close();
5594
- }
5595
5982
 
5596
5983
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5597
5984
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5599,6 +5986,17 @@ describe('Protocol V2 firmware update targets', () => {
5599
5986
  expect(writePayloads.map(payload => payload.overwrite)).toEqual([true, false]);
5600
5987
  expect(writePayloads.every(payload => payload.append === false)).toBe(true);
5601
5988
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
5989
+ expect(method.postMessage).toHaveBeenLastCalledWith({
5990
+ event: 'UI_EVENT',
5991
+ type: UI_REQUEST.FIRMWARE_PROGRESS,
5992
+ payload: expect.objectContaining({
5993
+ progress: 99,
5994
+ progressType: 'transferData',
5995
+ transferredBytes: 4097,
5996
+ totalBytes: 4097,
5997
+ elapsedMs: expect.any(Number),
5998
+ }),
5999
+ });
5602
6000
  });
5603
6001
 
5604
6002
  test('restarts the whole file from offset zero after an ambiguous chunk write failure', async () => {
@@ -5648,16 +6046,12 @@ describe('Protocol V2 firmware update targets', () => {
5648
6046
  method.postProgressMessage = jest.fn();
5649
6047
 
5650
6048
  try {
5651
- const source = await openFirmwareByteSource({
5652
- binary: new Uint8Array(8001).buffer,
5653
- });
5654
- await (method as any).protocolV2SourceUpdateProcess({
5655
- source,
6049
+ await (method as any).protocolV2CommonUpdateProcess({
6050
+ payload: new Uint8Array(8001).buffer,
5656
6051
  filePath: 'vol0:/firmware.bin',
5657
6052
  processedSize: 0,
5658
6053
  totalSize: 8001,
5659
6054
  });
5660
- await source?.close();
5661
6055
  } finally {
5662
6056
  setTimeoutSpy.mockRestore();
5663
6057
  }
@@ -5692,21 +6086,16 @@ describe('Protocol V2 firmware update targets', () => {
5692
6086
  });
5693
6087
  method.postProgressMessage = jest.fn();
5694
6088
  method.postTipMessage = jest.fn();
5695
- (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5696
6089
 
5697
- const source = await openFirmwareByteSource({
5698
- binary: new Uint8Array(4097).buffer,
5699
- });
5700
6090
  await expect(
5701
- (method as any).protocolV2SourceUpdateProcess({
5702
- source,
6091
+ (method as any).protocolV2WriteWholeFile({
6092
+ payload: new Uint8Array(4097).buffer,
5703
6093
  filePath: 'vol0:/firmware.bin',
5704
6094
  processedSize: 0,
5705
6095
  totalSize: 4097,
5706
6096
  })
5707
6097
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5708
- await source?.close();
5709
- expect(typedCall).toHaveBeenCalledTimes(6);
6098
+ expect(typedCall).toHaveBeenCalledTimes(2);
5710
6099
  });
5711
6100
 
5712
6101
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5739,24 +6128,21 @@ describe('Protocol V2 firmware update targets', () => {
5739
6128
  });
5740
6129
  method.postProgressMessage = jest.fn();
5741
6130
 
5742
- const source = await openFirmwareByteSource({
5743
- binary: new Uint8Array(1801).buffer,
6131
+ await (method as any).protocolV2CommonUpdateProcess({
6132
+ payload: new Uint8Array(1801).buffer,
6133
+ filePath: 'vol1:ble-firmware.bin',
6134
+ processedSize: 0,
6135
+ totalSize: 1801,
5744
6136
  });
5745
- try {
5746
- await (method as any).protocolV2SourceUpdateProcess({
5747
- source,
5748
- filePath: 'vol1:ble-firmware.bin',
5749
- processedSize: 0,
5750
- totalSize: 1801,
5751
- });
5752
- } finally {
5753
- await source?.close();
5754
- }
5755
6137
 
5756
6138
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5757
6139
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
5758
6140
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
5759
6141
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
6142
+ expect(typedCall.mock.calls.map(call => call[3])).toEqual([
6143
+ { writeWithResponse: true },
6144
+ { writeWithResponse: true },
6145
+ ]);
5760
6146
  });
5761
6147
 
5762
6148
  test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
@@ -5868,9 +6254,9 @@ describe('Protocol V2 firmware update method', () => {
5868
6254
  const typedCall = jest
5869
6255
  .fn()
5870
6256
  .mockRejectedValue(
5871
- Object.assign(
5872
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
- { name: 'NotFoundError' }
6257
+ new DOMException(
6258
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
6259
+ 'NotFoundError'
5874
6260
  )
5875
6261
  );
5876
6262
 
@@ -5924,7 +6310,7 @@ describe('Protocol V2 firmware update method', () => {
5924
6310
  expect(typedCall).not.toHaveBeenCalled();
5925
6311
  });
5926
6312
 
5927
- test.each([0, 2])(
6313
+ test.each([0, 1, 2])(
5928
6314
  'rejects firmware target %s because the Pro2 bootloader cannot install it',
5929
6315
  async targetId => {
5930
6316
  const typedCall = jest.fn();
@@ -5957,8 +6343,8 @@ describe('Protocol V2 firmware update method', () => {
5957
6343
  targets: [
5958
6344
  {
5959
6345
  target_id: undefined,
5960
- targetId: 1,
5961
- path: 'vol0:resource.crate.okpkg',
6346
+ targetId: 3,
6347
+ path: 'vol0:bootloader.okpkg',
5962
6348
  },
5963
6349
  ],
5964
6350
  } as any,
@@ -5970,7 +6356,7 @@ describe('Protocol V2 firmware update method', () => {
5970
6356
 
5971
6357
  await method.run();
5972
6358
  expect(typedCall.mock.calls[0][2]).toEqual({
5973
- targets: [{ target_id: 1, path: 'vol0:resource.crate.okpkg' }],
6359
+ targets: [{ target_id: 3, path: 'vol0:bootloader.okpkg' }],
5974
6360
  });
5975
6361
  });
5976
6362
 
@@ -6035,20 +6421,63 @@ describe('Protocol V2 firmware update method', () => {
6035
6421
  });
6036
6422
 
6037
6423
  describe('Protocol V2 firmware reconnect identity', () => {
6038
- test('keeps the host plan identity as the V4 reconnect anchor', () => {
6039
- const method = new FirmwareUpdateV4({
6040
- id: 1,
6041
- payload: {
6042
- method: 'firmwareUpdateV4',
6043
- platform: 'native',
6044
- expectedDeviceId: 'persisted-device-id',
6045
- },
6424
+ const createResourceFilesystemTypedCall = (
6425
+ stable: Array<{ type: string; size: number; headerHash: string }>,
6426
+ missingTypes: string[] = []
6427
+ ) => {
6428
+ const missing = new Set(missingTypes);
6429
+ const resourceByPath = new Map(
6430
+ stable.map(resource => [
6431
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6432
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6433
+ ],
6434
+ resource,
6435
+ ])
6436
+ );
6437
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6438
+ if (requestType === 'ResourceInventoryGet') {
6439
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
6440
+ }
6441
+ if (requestType === 'FilesystemPathInfoQuery') {
6442
+ const resource = resourceByPath.get(payload.path);
6443
+ const exists = Boolean(resource && !missing.has(resource.type));
6444
+ return {
6445
+ message: {
6446
+ exist: exists,
6447
+ directory: false,
6448
+ size: exists ? resource?.size : 0,
6449
+ },
6450
+ };
6451
+ }
6452
+ if (requestType === 'FilesystemFileRead') {
6453
+ const resource = resourceByPath.get(payload.file.path);
6454
+ if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6455
+ const header = new Uint8Array(0x52a0);
6456
+ const view = new DataView(header.buffer);
6457
+ 'OKPP'.split('').forEach((char, index) => {
6458
+ header[index] = char.charCodeAt(0);
6459
+ });
6460
+ 'RESC'.split('').forEach((char, index) => {
6461
+ header[0x08 + index] = char.charCodeAt(0);
6462
+ });
6463
+ view.setUint32(0x0c, header.byteLength, true);
6464
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
6465
+ header[0x240 + index] = Number.parseInt(
6466
+ resource.headerHash.slice(index * 2, index * 2 + 2),
6467
+ 16
6468
+ );
6469
+ }
6470
+ const offset = Number(payload.file.offset);
6471
+ const chunkLength = Number(payload.chunk_len);
6472
+ return {
6473
+ message: {
6474
+ data: header.slice(offset, offset + chunkLength),
6475
+ },
6476
+ };
6477
+ }
6478
+ throw new Error(`Unexpected request: ${requestType}`);
6046
6479
  });
6047
-
6048
- method.init();
6049
-
6050
- expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6051
- });
6480
+ };
6052
6481
 
6053
6482
  test('rejects a different physical serial before firmware transfer resumes', () => {
6054
6483
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
@@ -6059,15 +6488,15 @@ describe('Protocol V2 firmware reconnect identity', () => {
6059
6488
  ).not.toThrow();
6060
6489
  });
6061
6490
 
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
- );
6491
+ test('requires the same descriptor path when either physical serial is unavailable', () => {
6492
+ expect(() =>
6493
+ assertProtocolV2ReconnectIdentity('expected-serial', undefined, 'usb-path', 'usb-path')
6494
+ ).not.toThrow();
6495
+ expect(() =>
6496
+ assertProtocolV2ReconnectIdentity(undefined, 'actual-serial', 'usb-path', 'other-path')
6497
+ ).toThrow('identity unavailable');
6069
6498
  expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).toThrow(
6070
- expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6499
+ 'identity unavailable'
6071
6500
  );
6072
6501
  });
6073
6502
 
@@ -6086,6 +6515,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6086
6515
  },
6087
6516
  });
6088
6517
  (method as any).device = stubDevice({
6518
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
6089
6519
  features: { deviceId: 'wallet-derived-id' },
6090
6520
  getCommands: () => ({ typedCall }),
6091
6521
  });
@@ -6101,7 +6531,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6101
6531
  );
6102
6532
  });
6103
6533
 
6104
- test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
6534
+ test('allows firmware update startup when DeviceInfo has no physical serial', async () => {
6105
6535
  const method = new FirmwareUpdateV4({
6106
6536
  id: 1,
6107
6537
  payload: { method: 'firmwareUpdateV4' },
@@ -6111,42 +6541,204 @@ describe('Protocol V2 firmware reconnect identity', () => {
6111
6541
  message: { protocol_version: 1, hw: {} },
6112
6542
  });
6113
6543
  (method as any).device = stubDevice({
6544
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
6114
6545
  getCommands: () => ({ typedCall }),
6115
6546
  });
6116
6547
 
6117
- await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
6118
- errorCode: HardwareErrorCode.DeviceNotFound,
6119
- });
6548
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
6549
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
6550
+ expect((method as any).protocolV2ExpectedPath).toBeDefined();
6120
6551
  });
6121
6552
 
6122
- test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
6553
+ test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6123
6554
  const method = new FirmwareUpdateV4({
6124
6555
  id: 1,
6125
6556
  payload: {
6126
6557
  method: 'firmwareUpdateV4',
6127
- platform: 'native',
6128
- chunkSize: 1800,
6558
+ platform: 'web',
6559
+ targetsToUpdate: ['resource'],
6129
6560
  },
6130
6561
  });
6131
6562
  method.init();
6132
- const readChunkLengths: number[] = [];
6133
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6134
- if (requestType === 'FilesystemPathInfoQuery') {
6135
- return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
6136
- }
6137
- if (requestType === 'FilesystemFileRead') {
6138
- readChunkLengths.push(request.chunk_len);
6139
- return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
6140
- }
6141
- throw new Error(`Unexpected request: ${requestType}`);
6563
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6564
+ (type, index) => ({
6565
+ type,
6566
+ url: `https://example.com/${type}.okpkg`,
6567
+ size: 0x52a0 + index + 1,
6568
+ fileHash: 'a'.repeat(64),
6569
+ headerHash: index.toString(16).padStart(128, '0'),
6570
+ })
6571
+ );
6572
+ const typedCall = createResourceFilesystemTypedCall(stable);
6573
+ (method as any).device = stubDevice({
6574
+ getCommands: () => ({ typedCall }),
6575
+ getCurrentDeviceType: () => 'pro2',
6576
+ });
6577
+ const resourcesSpy = jest
6578
+ .spyOn(DataManager, 'getProtocolV2Resources')
6579
+ .mockReturnValue(stable as any);
6580
+ (method as any).downloadProtocolV2Resource = jest.fn();
6581
+
6582
+ await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6583
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6584
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6585
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6586
+ resourcesSpy.mockRestore();
6587
+ });
6588
+
6589
+ test('downloads only changed resources from loader inventory', async () => {
6590
+ const method = new FirmwareUpdateV4({
6591
+ id: 1,
6592
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6593
+ });
6594
+ method.init();
6595
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6596
+ (type, index) => ({
6597
+ type,
6598
+ url: `https://example.com/${type}.okpkg`,
6599
+ size: 0x52a0 + index + 1,
6600
+ fileHash: 'a'.repeat(64),
6601
+ headerHash: index.toString(16).padStart(128, '0'),
6602
+ })
6603
+ );
6604
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6605
+ (method as any).device = stubDevice({
6606
+ getCommands: () => ({ typedCall }),
6607
+ getCurrentDeviceType: () => 'pro2',
6608
+ });
6609
+ const resourcesSpy = jest
6610
+ .spyOn(DataManager, 'getProtocolV2Resources')
6611
+ .mockReturnValue(stable as any);
6612
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6613
+ Promise.resolve({
6614
+ name: `${resource.type}.okpkg`,
6615
+ binary: new ArrayBuffer(resource.size),
6616
+ devicePath: `vol0:/${resource.type}.okpkg`,
6617
+ })
6618
+ );
6619
+
6620
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6621
+
6622
+ expect(bundles).toHaveLength(1);
6623
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6624
+ resourcesSpy.mockRestore();
6625
+ });
6626
+
6627
+ test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6628
+ const method = new FirmwareUpdateV4({
6629
+ id: 1,
6630
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6142
6631
  });
6632
+ method.init();
6633
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6634
+ (type, index) => ({
6635
+ type,
6636
+ url: `https://example.com/${type}.okpkg`,
6637
+ size: 0x52a0 + index + 1,
6638
+ fileHash: 'a'.repeat(64),
6639
+ headerHash: index.toString(16).padStart(128, '0'),
6640
+ })
6641
+ );
6642
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6143
6643
  (method as any).device = stubDevice({
6144
6644
  getCommands: () => ({ typedCall }),
6645
+ getCurrentDeviceType: () => 'pro2',
6646
+ });
6647
+ const resourcesSpy = jest
6648
+ .spyOn(DataManager, 'getProtocolV2Resources')
6649
+ .mockReturnValue(stable as any);
6650
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6651
+ Promise.resolve({
6652
+ name: `${resource.type}.okpkg`,
6653
+ binary: new ArrayBuffer(resource.size),
6654
+ devicePath: `vol0:/${resource.type}.okpkg`,
6655
+ })
6656
+ );
6657
+
6658
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6659
+
6660
+ expect(bundles).toHaveLength(1);
6661
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6662
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6663
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6664
+ resourcesSpy.mockRestore();
6665
+ });
6666
+
6667
+ test('does not resolve boot resources unless the optional target is selected', async () => {
6668
+ const method = new FirmwareUpdateV4({
6669
+ id: 1,
6670
+ payload: { method: 'firmwareUpdateV4', platform: 'web' },
6671
+ });
6672
+ method.init();
6673
+ const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6674
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6675
+
6676
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6677
+
6678
+ expect(configSpy).not.toHaveBeenCalled();
6679
+ expect(downloadSpy).not.toHaveBeenCalled();
6680
+ });
6681
+
6682
+ test('downloads and maps selected boot resources as direct RES files', async () => {
6683
+ const bytes = new Uint8Array([1, 2, 3, 4]);
6684
+ const binary = bytes.buffer as ArrayBuffer;
6685
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6686
+ const resource = {
6687
+ required: false as const,
6688
+ target: 'RES' as const,
6689
+ files: [
6690
+ {
6691
+ name: 'bootloader_crest.bin',
6692
+ url: 'https://example.com/bootloader_crest.bin',
6693
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6694
+ size: bytes.byteLength,
6695
+ fileHash,
6696
+ },
6697
+ ],
6698
+ };
6699
+ const method = new FirmwareUpdateV4({
6700
+ id: 1,
6701
+ payload: {
6702
+ method: 'firmwareUpdateV4',
6703
+ platform: 'web',
6704
+ targetsToUpdate: ['boot_resources'],
6705
+ },
6706
+ });
6707
+ method.init();
6708
+ (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
6709
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6710
+ jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6711
+
6712
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([
6713
+ {
6714
+ name: 'bootloader_crest.bin',
6715
+ binary,
6716
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6717
+ },
6718
+ ]);
6719
+ });
6720
+
6721
+ test('rejects a manually supplied resource file when its manifest hash does not match', () => {
6722
+ const method = new FirmwareUpdateV4({
6723
+ id: 1,
6724
+ payload: {
6725
+ method: 'firmwareUpdateV4',
6726
+ platform: 'web',
6727
+ resourceFiles: [
6728
+ {
6729
+ binary: new Uint8Array([1, 2, 3]).buffer,
6730
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6731
+ size: 3,
6732
+ fileHash: '00'.repeat(32),
6733
+ },
6734
+ ],
6735
+ },
6145
6736
  });
6737
+ method.init();
6146
6738
 
6147
- await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
- expect(readChunkLengths.length).toBeGreaterThan(1);
6149
- expect(Math.max(...readChunkLengths)).toBe(900);
6739
+ expect(() => (method as any).prepareExplicitProtocolV2ResourceFiles()).toThrow(
6740
+ 'SHA-256 mismatch'
6741
+ );
6150
6742
  });
6151
6743
  });
6152
6744
 
@@ -6265,334 +6857,253 @@ describe('Protocol V2 protected method execution', () => {
6265
6857
 
6266
6858
  expect(method.protocolV2UiInteraction).toEqual({
6267
6859
  request: 'button',
6268
- source: 'method-lifecycle',
6269
- reason: 'device-management',
6270
- completion: 'operation-completed',
6271
- deviceOnly: true,
6272
- operation: 'change-label',
6273
- });
6274
- });
6275
-
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',
6860
+ source: 'method-lifecycle',
6861
+ reason: 'device-management',
6862
+ completion: 'operation-completed',
6863
+ deviceOnly: true,
6864
+ operation: 'change-label',
6385
6865
  });
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
6866
  });
6395
6867
 
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
- };
6868
+ test.each([
6869
+ ['deviceInfoGet', DeviceInfoGet],
6870
+ ['deviceStatusGet', DeviceStatusGet],
6871
+ ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6872
+ const method = new MethodClass({
6873
+ id: 1,
6874
+ payload: { method: _name },
6875
+ } as any);
6876
+ method.init();
6427
6877
 
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();
6878
+ expect(method.unlockPolicy).toBe('none');
6433
6879
  });
6434
6880
 
6435
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6881
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6436
6882
  const calls: string[] = [];
6883
+ const features = { unlocked: true };
6437
6884
  const method = {
6438
- name: 'evmSignMessage',
6439
- payload: { passphraseState: 'hidden-state' },
6885
+ name: 'confluxSignMessageCIP23',
6886
+ unlockPolicy: 'none',
6440
6887
  useDevicePassphraseState: true,
6441
- unlockPolicy: 'retry-on-locked',
6888
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6442
6889
  run: jest.fn(() => {
6443
6890
  calls.push('run');
6444
- return Promise.resolve({ message: 'ok' });
6891
+ return Promise.resolve({ address: '0x1' });
6445
6892
  }),
6446
6893
  };
6447
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6448
- if (requestType === 'ProtocolInfoRequest') {
6449
- calls.push('negotiate-session');
6450
- return Promise.resolve({ message: { version: 2 } });
6451
- }
6452
- if (requestType === 'DeviceSessionGet') {
6453
- calls.push('resume-hidden-session');
6454
- expect(request).toEqual({ session_id: 'hidden-session' });
6455
- return Promise.resolve({
6456
- message: {
6457
- session_id: 'hidden-session',
6458
- btc_test_address: 'hidden-state',
6459
- },
6460
- });
6461
- }
6462
- throw new Error(`Unexpected request: ${requestType}`);
6463
- });
6464
- const features = {
6465
- unlocked: false,
6466
- unlockedAttachPin: true,
6467
- passphraseProtection: true,
6468
- };
6469
6894
  const device = {
6470
6895
  features,
6471
- passphraseState: 'hidden-state',
6472
- commands: { typedCall },
6896
+ commands: {
6897
+ typedCall: jest.fn(() => {
6898
+ calls.push('status');
6899
+ return Promise.resolve({ message: { unlocked: true } });
6900
+ }),
6901
+ },
6473
6902
  isProtocolV2: () => true,
6474
- unlockDevice: jest.fn(() => {
6475
- calls.push('unlock-main');
6476
- features.unlocked = true;
6477
- features.unlockedAttachPin = false;
6478
- return Promise.resolve();
6903
+ isBootloader: () => false,
6904
+ isRomloader: () => false,
6905
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6906
+ features.unlocked = status.unlocked === true;
6907
+ return features;
6479
6908
  }),
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')),
6909
+ unlockDevice: jest.fn(),
6910
+ };
6911
+ const uiCoordinator = {
6912
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6913
+ enterUnlockInteraction: jest.fn(),
6485
6914
  };
6486
6915
 
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
- ]);
6916
+ await expect(
6917
+ runMethodWithUnlockPolicy(method as any, device as any, {
6918
+ uiCoordinator: uiCoordinator as any,
6919
+ })
6920
+ ).resolves.toEqual({ address: '0x1' });
6921
+
6922
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6923
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6924
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6925
+ expect(method.run).toHaveBeenCalledTimes(1);
6498
6926
  });
6499
6927
 
6500
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6928
+ test('validates the fresh device identity before starting unlock', async () => {
6929
+ const calls: string[] = [];
6930
+ const identityError = new Error('Unexpected device');
6501
6931
  const method = {
6502
- name: 'evmGetAddress',
6503
- payload: { useEmptyPassphrase: true },
6932
+ name: 'evmSignMessage',
6933
+ unlockPolicy: 'none',
6504
6934
  useDevicePassphraseState: true,
6505
- unlockPolicy: 'retry-on-locked',
6506
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6935
+ run: jest.fn(),
6507
6936
  };
6937
+ const features = { unlocked: false };
6508
6938
  const device = {
6509
- features: { unlocked: false },
6510
- passphraseState: 'stale-hidden-state',
6939
+ features,
6940
+ commands: {
6941
+ typedCall: jest.fn(() => {
6942
+ calls.push('status');
6943
+ return Promise.resolve({ message: { unlocked: false } });
6944
+ }),
6945
+ },
6511
6946
  isProtocolV2: () => true,
6512
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6947
+ isBootloader: () => false,
6948
+ isRomloader: () => false,
6949
+ updateProtocolV2Status: jest.fn(() => features),
6950
+ unlockDevice: jest.fn(),
6513
6951
  };
6514
6952
 
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);
6953
+ await expect(
6954
+ runMethodWithUnlockPolicy(method as any, device as any, {
6955
+ afterStatusBeforeUnlock: () => {
6956
+ calls.push('identity');
6957
+ throw identityError;
6958
+ },
6959
+ })
6960
+ ).rejects.toBe(identityError);
6961
+
6962
+ expect(calls).toEqual(['status', 'identity']);
6963
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6964
+ expect(method.run).not.toHaveBeenCalled();
6520
6965
  });
6521
6966
 
6522
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6967
+ test('unlocks before business and never replays the callback', async () => {
6523
6968
  const calls: string[] = [];
6969
+ const features = { unlocked: true };
6524
6970
  const method = {
6525
- name: 'deviceSettingsPageShow',
6971
+ name: 'evmSignMessage',
6526
6972
  unlockPolicy: 'unlock-before-run',
6527
- protocolV2UiInteraction: { reason: 'settings-page' },
6973
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6528
6974
  run: jest.fn(() => {
6529
6975
  calls.push('run');
6530
- return Promise.resolve({ message: 'ok' });
6976
+ return Promise.resolve({ signature: 'signed' });
6531
6977
  }),
6532
6978
  };
6533
- const features = { unlocked: true };
6534
6979
  const device = {
6535
6980
  features,
6536
6981
  commands: {
6537
6982
  typedCall: jest.fn(() => {
6538
- calls.push('refresh-status');
6983
+ calls.push('status');
6539
6984
  return Promise.resolve({ message: { unlocked: false } });
6540
6985
  }),
6541
6986
  },
6542
6987
  isProtocolV2: () => true,
6988
+ isBootloader: () => false,
6989
+ isRomloader: () => false,
6543
6990
  updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6544
- features.unlocked = status.unlocked ?? features.unlocked;
6991
+ features.unlocked = status.unlocked === true;
6545
6992
  return features;
6546
6993
  }),
6547
6994
  unlockDevice: jest.fn(() => {
6548
6995
  calls.push('unlock');
6549
6996
  features.unlocked = true;
6550
- return Promise.resolve();
6997
+ return Promise.resolve(features);
6551
6998
  }),
6552
6999
  };
6553
7000
  const uiCoordinator = {
6554
7001
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6555
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6556
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
7002
+ enterUnlockInteraction: jest.fn(() => {
7003
+ calls.push('unlock-prompt');
7004
+ return undefined;
7005
+ }),
6557
7006
  };
6558
7007
 
6559
7008
  await expect(
6560
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6561
- ).resolves.toEqual({ message: 'ok' });
6562
- expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6563
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
7009
+ runMethodWithUnlockPolicy(method as any, device as any, {
7010
+ uiCoordinator: uiCoordinator as any,
7011
+ prepare: () => {
7012
+ calls.push('wallet-session');
7013
+ return Promise.resolve();
7014
+ },
7015
+ })
7016
+ ).resolves.toEqual({ signature: 'signed' });
7017
+
7018
+ expect(calls).toEqual([
7019
+ 'status',
7020
+ 'unlock-prompt',
7021
+ 'unlock',
7022
+ 'wallet-session',
7023
+ 'method-prompt',
7024
+ 'run',
7025
+ ]);
7026
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6564
7027
  expect(method.run).toHaveBeenCalledTimes(1);
6565
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6567
7028
  });
6568
7029
 
6569
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
- const features = { unlocked: false };
7030
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
7031
+ const method = {
7032
+ name: 'evmGetAddress',
7033
+ unlockPolicy: 'unlock-before-run',
7034
+ run: jest.fn(),
7035
+ };
7036
+ const device = {
7037
+ features: { unlocked: undefined },
7038
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
7039
+ isProtocolV2: () => true,
7040
+ isBootloader: () => false,
7041
+ isRomloader: () => false,
7042
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
7043
+ unlockDevice: jest.fn(),
7044
+ };
7045
+
7046
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
7047
+ errorCode: HardwareErrorCode.RuntimeError,
7048
+ });
7049
+ expect(device.unlockDevice).not.toHaveBeenCalled();
7050
+ expect(method.run).not.toHaveBeenCalled();
7051
+ });
7052
+
7053
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
7054
+ const error = deviceLockedError();
6571
7055
  const method = {
6572
- name: 'deviceSettings',
7056
+ name: 'evmSignMessage',
6573
7057
  unlockPolicy: 'unlock-before-run',
6574
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
7058
+ run: jest.fn().mockRejectedValue(error),
6575
7059
  };
7060
+ const features = { unlocked: true };
6576
7061
  const device = {
6577
7062
  features,
6578
- commands: {
6579
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
- },
7063
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6581
7064
  isProtocolV2: () => true,
6582
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
- features.unlocked = status.unlocked ?? features.unlocked;
6584
- return features;
6585
- }),
7065
+ isBootloader: () => false,
7066
+ isRomloader: () => false,
7067
+ updateProtocolV2Status: jest.fn(() => features),
6586
7068
  unlockDevice: jest.fn(),
6587
7069
  };
6588
7070
 
6589
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6590
- message: 'ok',
6591
- });
7071
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6592
7072
  expect(device.unlockDevice).not.toHaveBeenCalled();
6593
7073
  expect(method.run).toHaveBeenCalledTimes(1);
6594
7074
  });
6595
7075
 
7076
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
7077
+ const features = { unlocked: true };
7078
+ const context = createProtocolV2UnlockContext();
7079
+ const firstMethod = {
7080
+ name: 'allNetworkGetAddress',
7081
+ unlockPolicy: 'unlock-before-run',
7082
+ run: jest.fn().mockResolvedValue({ root: true }),
7083
+ };
7084
+ const nestedMethod = {
7085
+ name: 'evmGetAddress',
7086
+ unlockPolicy: 'unlock-before-run',
7087
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
7088
+ };
7089
+ const device = {
7090
+ features,
7091
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
7092
+ isProtocolV2: () => true,
7093
+ isBootloader: () => false,
7094
+ isRomloader: () => false,
7095
+ updateProtocolV2Status: jest.fn(() => features),
7096
+ unlockDevice: jest.fn(),
7097
+ };
7098
+
7099
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
7100
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
7101
+
7102
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
7103
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
7104
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
7105
+ });
7106
+
6596
7107
  test.each(['bootloader', 'romloader'] as const)(
6597
7108
  'skips DeviceStatus and unlock in Protocol V2 %s mode',
6598
7109
  async mode => {
@@ -6610,7 +7121,7 @@ describe('Protocol V2 protected method execution', () => {
6610
7121
  unlockDevice: jest.fn(),
6611
7122
  };
6612
7123
 
6613
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
7124
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6614
7125
  message: 'updating',
6615
7126
  });
6616
7127
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6619,157 +7130,30 @@ describe('Protocol V2 protected method execution', () => {
6619
7130
  }
6620
7131
  );
6621
7132
 
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 () => {
7133
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6653
7134
  for (const [isProtocolV2, unlockPolicy] of [
6654
- [false, 'retry-on-locked'],
7135
+ [false, 'unlock-before-run'],
6655
7136
  [true, 'none'],
6656
7137
  ] as const) {
6657
- const error = deviceLockedError();
6658
7138
  const method = {
6659
7139
  unlockPolicy,
6660
- run: jest.fn().mockRejectedValue(error),
7140
+ useDevicePassphraseState: false,
7141
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6661
7142
  };
6662
7143
  const device = {
7144
+ commands: { typedCall: jest.fn() },
6663
7145
  isProtocolV2: () => isProtocolV2,
6664
7146
  unlockDevice: jest.fn(),
6665
7147
  };
6666
7148
 
6667
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
7149
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
7150
+ message: 'ok',
7151
+ });
7152
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6668
7153
  expect(device.unlockDevice).not.toHaveBeenCalled();
6669
7154
  expect(method.run).toHaveBeenCalledTimes(1);
6670
7155
  }
6671
7156
  });
6672
-
6673
- test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6674
- const method = {
6675
- name: 'uploadPortfolio',
6676
- unlockPolicy: 'none',
6677
- protocolV2UiMode: 'none',
6678
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6679
- };
6680
- const device = {
6681
- features: { unlocked: false },
6682
- isProtocolV2: () => true,
6683
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6684
- };
6685
- const uiCoordinator = {
6686
- enterMethodInteraction: jest.fn(),
6687
- enterUnlockInteraction: jest.fn(),
6688
- resumeMethodInteraction: jest.fn(),
6689
- };
6690
-
6691
- await expect(
6692
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6693
- ).resolves.toEqual({ message: 'ok' });
6694
- expect(device.unlockDevice).not.toHaveBeenCalled();
6695
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6696
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6697
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6698
- });
6699
-
6700
- test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6701
- const error = deviceLockedError();
6702
- const method = {
6703
- name: 'uploadPortfolio',
6704
- unlockPolicy: 'none',
6705
- run: jest.fn().mockRejectedValue(error),
6706
- };
6707
- const device = {
6708
- features: { unlocked: true },
6709
- isProtocolV2: () => true,
6710
- unlockDevice: jest.fn(),
6711
- };
6712
-
6713
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6714
- expect(method.run).toHaveBeenCalledTimes(1);
6715
- expect(device.unlockDevice).not.toHaveBeenCalled();
6716
- });
6717
-
6718
- test('does not retry when unlock fails or when the retry is still locked', async () => {
6719
- const initialError = deviceLockedError();
6720
- const unlockError = new Error('PIN cancelled');
6721
- const unlockFailMethod = {
6722
- unlockPolicy: 'retry-on-locked',
6723
- run: jest.fn().mockRejectedValue(initialError),
6724
- };
6725
- const unlockFailDevice = {
6726
- isProtocolV2: () => true,
6727
- unlockDevice: jest.fn().mockRejectedValue(unlockError),
6728
- };
6729
- const unlockFailCoordinator = {
6730
- enterMethodInteraction: jest.fn(),
6731
- enterUnlockInteraction: jest.fn(),
6732
- resumeMethodInteraction: jest.fn(),
6733
- };
6734
-
6735
- await expect(
6736
- runMethodWithUnlockRetry(
6737
- unlockFailMethod as any,
6738
- unlockFailDevice as any,
6739
- unlockFailCoordinator as any
6740
- )
6741
- ).rejects.toBe(unlockError);
6742
- expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6743
- expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6744
- expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6745
-
6746
- const retryError = deviceLockedError();
6747
- const retryFailMethod = {
6748
- unlockPolicy: 'retry-on-locked',
6749
- run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6750
- };
6751
- const retryFailDevice = {
6752
- isProtocolV2: () => true,
6753
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6754
- };
6755
- const retryFailCoordinator = {
6756
- enterMethodInteraction: jest.fn(),
6757
- enterUnlockInteraction: jest.fn(),
6758
- resumeMethodInteraction: jest.fn(),
6759
- };
6760
-
6761
- await expect(
6762
- runMethodWithUnlockRetry(
6763
- retryFailMethod as any,
6764
- retryFailDevice as any,
6765
- retryFailCoordinator as any
6766
- )
6767
- ).rejects.toBe(retryError);
6768
- expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6769
- expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6770
- expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6771
- expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6772
- });
6773
7157
  });
6774
7158
 
6775
7159
  describe('Protocol V2 current low-level methods', () => {
@@ -6901,7 +7285,9 @@ describe('Protocol V2 current low-level methods', () => {
6901
7285
 
6902
7286
  await method.run();
6903
7287
 
6904
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
7288
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
7289
+ eventless_wallet_session: true,
7290
+ });
6905
7291
  });
6906
7292
 
6907
7293
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {
@@ -6977,6 +7363,29 @@ describe('Protocol V2 current low-level methods', () => {
6977
7363
 
6978
7364
  await expect(method.run()).resolves.toBe(features);
6979
7365
  expect(unlockDevice).toHaveBeenCalledTimes(1);
7366
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
7367
+ emitUiEvent: false,
7368
+ });
7369
+ });
7370
+
7371
+ test('forwards an explicit PIN type when unlocking a Protocol V2 device', async () => {
7372
+ const method = new DeviceUnlock({
7373
+ id: 1,
7374
+ payload: {
7375
+ method: 'deviceUnlock',
7376
+ pinType: DeviceSessionPinType.Any,
7377
+ },
7378
+ });
7379
+ method.init();
7380
+
7381
+ const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
7382
+ (method as any).device = { unlockDevice };
7383
+
7384
+ await method.run();
7385
+
7386
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, {
7387
+ emitUiEvent: false,
7388
+ });
6980
7389
  });
6981
7390
 
6982
7391
  test.each([