@onekeyfe/hd-core 1.2.0-alpha.67 → 1.2.0-alpha.69

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 (316) 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 +226 -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-firmware-targets.test.ts +33 -1
  27. package/__tests__/protocol-v2-resources.test.ts +343 -0
  28. package/__tests__/protocol-v2-ui-lifecycle.test.ts +91 -0
  29. package/__tests__/protocol-v2-unlock-policy.test.ts +300 -37
  30. package/__tests__/protocol-v2.test.ts +1586 -1213
  31. package/__tests__/protocolV2FileWrite.test.ts +193 -0
  32. package/__tests__/protocolV2UiInteraction.test.ts +63 -0
  33. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  34. package/__tests__/search-devices.test.ts +12 -3
  35. package/__tests__/tron-sign-message-init.test.ts +2 -2
  36. package/__tests__/uiProgressThrottle.test.ts +74 -0
  37. package/__tests__/uiPromiseRegistry.test.ts +115 -0
  38. package/dist/api/BaseMethod.d.ts +3 -1
  39. package/dist/api/BaseMethod.d.ts.map +1 -1
  40. package/dist/api/CheckAllFirmwareRelease.d.ts +3 -1
  41. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  42. package/dist/api/DetectDeviceConnectProtocol.d.ts +7 -0
  43. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +1 -0
  44. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  45. package/dist/api/FirmwareUpdateV2.d.ts +0 -10
  46. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  47. package/dist/api/FirmwareUpdateV3.d.ts +3 -7
  48. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  49. package/dist/api/FirmwareUpdateV4.d.ts +15 -27
  50. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  51. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  52. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  53. package/dist/api/SearchDevices.d.ts.map +1 -1
  54. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  55. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  56. package/dist/api/device/DeviceUnlock.d.ts +2 -2
  57. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  58. package/dist/api/device/DeviceUpdateBootloader.d.ts +1 -5
  59. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  60. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +4 -11
  61. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  62. package/dist/api/firmware/getBinary.d.ts +0 -7
  63. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  64. package/dist/api/firmware/updateBootloader.d.ts +0 -2
  65. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  66. package/dist/api/firmware/uploadFirmware.d.ts +1 -9
  67. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  68. package/dist/api/helpers/protocolV2FileWrite.d.ts +9 -0
  69. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  70. package/dist/api/index.d.ts +2 -0
  71. package/dist/api/index.d.ts.map +1 -1
  72. package/dist/api/kaspa/KaspaGetAddress.d.ts +1 -1
  73. package/dist/api/kaspa/KaspaSignTransaction.d.ts +1 -1
  74. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  75. package/dist/api/polkadot/PolkadotGetAddress.d.ts +1 -1
  76. package/dist/api/polkadot/networks.d.ts +1 -1
  77. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +31 -0
  78. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  79. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  80. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  81. package/dist/api/solana/SolSignMessage.d.ts +1 -1
  82. package/dist/api/solana/SolSignOffchainMessage.d.ts +1 -1
  83. package/dist/api/solana/SolSignTransaction.d.ts +2 -2
  84. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  85. package/dist/api/sui/SuiGetAddress.d.ts +1 -1
  86. package/dist/api/sui/SuiGetPublicKey.d.ts +1 -1
  87. package/dist/api/sui/SuiSignMessage.d.ts +1 -1
  88. package/dist/api/sui/SuiSignTransaction.d.ts +1 -1
  89. package/dist/api/ton/TonGetAddress.d.ts +1 -1
  90. package/dist/api/ton/TonSignMessage.d.ts +1 -1
  91. package/dist/api/ton/TonSignProof.d.ts +1 -1
  92. package/dist/api/tron/TronSignMessage.d.ts +2 -2
  93. package/dist/api/tron/TronSignTransaction.d.ts +1 -1
  94. package/dist/core/deviceEventRegistration.d.ts +2 -0
  95. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  96. package/dist/core/index.d.ts.map +1 -1
  97. package/dist/core/uiPromiseRegistry.d.ts +6 -0
  98. package/dist/core/uiPromiseRegistry.d.ts.map +1 -0
  99. package/dist/data-manager/DataManager.d.ts +9 -4
  100. package/dist/data-manager/DataManager.d.ts.map +1 -1
  101. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  102. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  103. package/dist/device/Device.d.ts +29 -6
  104. package/dist/device/Device.d.ts.map +1 -1
  105. package/dist/device/DeviceCommands.d.ts +4 -4
  106. package/dist/device/DeviceCommands.d.ts.map +1 -1
  107. package/dist/device/DeviceConnector.d.ts +2 -1
  108. package/dist/device/DeviceConnector.d.ts.map +1 -1
  109. package/dist/device/DevicePool.d.ts.map +1 -1
  110. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts.map +1 -1
  111. package/dist/events/device.d.ts +4 -0
  112. package/dist/events/device.d.ts.map +1 -1
  113. package/dist/events/logBlockEvent.d.ts.map +1 -1
  114. package/dist/events/ui-promise.d.ts +4 -2
  115. package/dist/events/ui-promise.d.ts.map +1 -1
  116. package/dist/events/ui-request.d.ts +31 -2
  117. package/dist/events/ui-request.d.ts.map +1 -1
  118. package/dist/events/ui-response.d.ts +10 -2
  119. package/dist/events/ui-response.d.ts.map +1 -1
  120. package/dist/index.d.ts +243 -247
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +3135 -3785
  123. package/dist/inject.d.ts +6 -1
  124. package/dist/inject.d.ts.map +1 -1
  125. package/dist/lowLevelInject.d.ts.map +1 -1
  126. package/dist/protocols/protocol-v2/features.d.ts +0 -4
  127. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  128. package/dist/protocols/protocol-v2/firmware.d.ts.map +1 -1
  129. package/dist/protocols/protocol-v2/resources.d.ts +30 -0
  130. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -0
  131. package/dist/protocols/protocol-v2/uiInteraction.d.ts +7 -4
  132. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  133. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  134. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  135. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  136. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  137. package/dist/protocols/protocol-v2/walletSession.d.ts +6 -1
  138. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  139. package/dist/topLevelInject.d.ts.map +1 -1
  140. package/dist/types/api/checkAllFirmwareRelease.d.ts +3 -5
  141. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  142. package/dist/types/api/detectDeviceConnectProtocol.d.ts +4 -0
  143. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +1 -0
  144. package/dist/types/api/deviceUnlock.d.ts +5 -1
  145. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  146. package/dist/types/api/deviceUpdateBootloader.d.ts +0 -6
  147. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  148. package/dist/types/api/export.d.ts +2 -4
  149. package/dist/types/api/export.d.ts.map +1 -1
  150. package/dist/types/api/firmwareUpdate.d.ts +4 -70
  151. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  152. package/dist/types/api/index.d.ts +7 -10
  153. package/dist/types/api/index.d.ts.map +1 -1
  154. package/dist/types/api/protocolV2.d.ts +3 -0
  155. package/dist/types/api/protocolV2.d.ts.map +1 -1
  156. package/dist/types/device.d.ts +2 -2
  157. package/dist/types/device.d.ts.map +1 -1
  158. package/dist/types/params.d.ts +1 -0
  159. package/dist/types/params.d.ts.map +1 -1
  160. package/dist/types/settings.d.ts +38 -21
  161. package/dist/types/settings.d.ts.map +1 -1
  162. package/dist/utils/deviceFeaturesCompat.d.ts +0 -1
  163. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  164. package/dist/utils/deviceFeaturesUtils.d.ts +4 -0
  165. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  166. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  167. package/dist/utils/deviceSettings.d.ts.map +1 -1
  168. package/dist/utils/homescreen.d.ts +4 -0
  169. package/dist/utils/homescreen.d.ts.map +1 -1
  170. package/dist/utils/index.d.ts +1 -1
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/patch.d.ts +1 -1
  173. package/dist/utils/patch.d.ts.map +1 -1
  174. package/dist/utils/pro2Nft.d.ts +31 -0
  175. package/dist/utils/pro2Nft.d.ts.map +1 -0
  176. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  177. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  178. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  179. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  180. package/package.json +4 -4
  181. package/src/api/BaseMethod.ts +8 -10
  182. package/src/api/CheckAllFirmwareRelease.ts +78 -64
  183. package/src/api/DetectDeviceConnectProtocol.ts +18 -0
  184. package/src/api/FirmwareUpdate.ts +7 -15
  185. package/src/api/FirmwareUpdateV2.ts +126 -298
  186. package/src/api/FirmwareUpdateV3.ts +63 -265
  187. package/src/api/FirmwareUpdateV4.ts +645 -1018
  188. package/src/api/GetPassphraseState.ts +7 -1
  189. package/src/api/OpenWalletSession.ts +71 -17
  190. package/src/api/SearchDevices.ts +3 -1
  191. package/src/api/allnetwork/AllNetworkGetAddress.ts +21 -21
  192. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +36 -11
  193. package/src/api/device/DeviceSettings.ts +5 -4
  194. package/src/api/device/DeviceUnlock.ts +8 -3
  195. package/src/api/device/DeviceUpdateBootloader.ts +6 -125
  196. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +27 -83
  197. package/src/api/firmware/updateBootloader.ts +4 -19
  198. package/src/api/firmware/uploadFirmware.ts +39 -144
  199. package/src/api/helpers/protocolV2FileWrite.ts +252 -63
  200. package/src/api/index.ts +2 -0
  201. package/src/api/kaspa/KaspaGetAddress.ts +1 -1
  202. package/src/api/kaspa/KaspaSignTransaction.ts +2 -13
  203. package/src/api/polkadot/networks.ts +3 -3
  204. package/src/api/protocol-v2/DeviceUploadNft.ts +197 -0
  205. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  206. package/src/api/protocol-v2/helpers.ts +1 -1
  207. package/src/api/solana/SolSignMessage.ts +1 -1
  208. package/src/api/solana/SolSignOffchainMessage.ts +1 -1
  209. package/src/api/solana/SolSignTransaction.ts +2 -2
  210. package/src/api/sui/SuiGetAddress.ts +1 -1
  211. package/src/api/sui/SuiGetPublicKey.ts +1 -1
  212. package/src/api/sui/SuiSignMessage.ts +1 -1
  213. package/src/api/sui/SuiSignTransaction.ts +1 -1
  214. package/src/api/ton/TonGetAddress.ts +1 -1
  215. package/src/api/ton/TonSignMessage.ts +1 -1
  216. package/src/api/ton/TonSignProof.ts +1 -1
  217. package/src/api/tron/TronSignMessage.ts +2 -2
  218. package/src/api/tron/TronSignTransaction.ts +1 -1
  219. package/src/core/deviceEventRegistration.ts +4 -0
  220. package/src/core/index.ts +213 -111
  221. package/src/core/uiPromiseRegistry.ts +63 -0
  222. package/src/data/messages/messages-protocol-v2.json +25 -0
  223. package/src/data-manager/DataManager.ts +113 -41
  224. package/src/data-manager/TransportManager.ts +6 -0
  225. package/src/data-manager/connectSettings.ts +0 -11
  226. package/src/device/Device.ts +274 -49
  227. package/src/device/DeviceCommands.ts +31 -4
  228. package/src/device/DeviceConnector.ts +33 -13
  229. package/src/device/DevicePool.ts +21 -7
  230. package/src/deviceProfile/buildDeviceFeatures.ts +2 -2
  231. package/src/deviceProfile/protocolV2DeviceIdentity.ts +3 -0
  232. package/src/events/device.ts +4 -0
  233. package/src/events/logBlockEvent.ts +1 -0
  234. package/src/events/ui-promise.ts +4 -2
  235. package/src/events/ui-request.ts +36 -2
  236. package/src/events/ui-response.ts +12 -2
  237. package/src/index.ts +0 -5
  238. package/src/inject.ts +60 -24
  239. package/src/lowLevelInject.ts +7 -8
  240. package/src/protocols/protocol-v2/features.ts +10 -7
  241. package/src/protocols/protocol-v2/firmware.ts +2 -0
  242. package/src/protocols/protocol-v2/resources.ts +394 -0
  243. package/src/protocols/protocol-v2/uiInteraction.ts +46 -6
  244. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  245. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +131 -0
  246. package/src/protocols/protocol-v2/walletSession.ts +214 -36
  247. package/src/topLevelInject.ts +7 -8
  248. package/src/types/api/checkAllFirmwareRelease.ts +3 -5
  249. package/src/types/api/detectDeviceConnectProtocol.ts +6 -0
  250. package/src/types/api/deviceUnlock.ts +12 -1
  251. package/src/types/api/deviceUpdateBootloader.ts +0 -6
  252. package/src/types/api/export.ts +1 -18
  253. package/src/types/api/firmwareUpdate.ts +6 -78
  254. package/src/types/api/index.ts +13 -14
  255. package/src/types/api/protocolV2.ts +13 -0
  256. package/src/types/device.ts +15 -3
  257. package/src/types/params.ts +7 -1
  258. package/src/types/settings.ts +61 -22
  259. package/src/utils/deviceFeaturesCompat.ts +5 -6
  260. package/src/utils/deviceFeaturesUtils.ts +14 -3
  261. package/src/utils/deviceInfoUtils.ts +2 -0
  262. package/src/utils/deviceSettings.ts +3 -0
  263. package/src/utils/homescreen.ts +32 -6
  264. package/src/utils/index.ts +6 -1
  265. package/src/utils/pro2Nft.ts +142 -0
  266. package/src/utils/pro2Wallpaper.ts +35 -8
  267. package/src/utils/uiProgressThrottle.ts +63 -0
  268. package/__tests__/device-initialize-timeout.test.ts +0 -56
  269. package/__tests__/firmware-update/check-all-firmware-release.test.ts +0 -175
  270. package/__tests__/firmware-update/device-update-bootloader.test.ts +0 -87
  271. package/__tests__/firmware-update/firmware-artifact-source.test.ts +0 -119
  272. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -70
  273. package/__tests__/firmware-update/firmware-preparation-error.test.ts +0 -27
  274. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +0 -200
  275. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +0 -13
  276. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +0 -394
  277. package/__tests__/firmware-update/firmware-update-plan.test.ts +0 -593
  278. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +0 -231
  279. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +0 -58
  280. package/__tests__/firmware-update/firmware-upload-source.test.ts +0 -70
  281. package/dist/api/firmware/FirmwareArtifactSource.d.ts +0 -25
  282. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +0 -1
  283. package/dist/api/firmware/FirmwareHostBinding.d.ts +0 -6
  284. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +0 -1
  285. package/dist/api/firmware/FirmwarePreparationError.d.ts +0 -3
  286. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +0 -1
  287. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +0 -3
  288. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +0 -1
  289. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -25
  290. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +0 -1
  291. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +0 -32
  292. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +0 -1
  293. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  294. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  295. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  296. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  297. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  298. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  299. package/dist/types/api/firmwareUpdateCapabilities.d.ts +0 -9
  300. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +0 -1
  301. package/dist/types/api/firmwareUpdatePlan.d.ts +0 -28
  302. package/dist/types/api/firmwareUpdatePlan.d.ts.map +0 -1
  303. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +0 -42
  304. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +0 -1
  305. package/src/api/firmware/FirmwareArtifactSource.ts +0 -278
  306. package/src/api/firmware/FirmwareHostBinding.ts +0 -100
  307. package/src/api/firmware/FirmwarePreparationError.ts +0 -12
  308. package/src/api/firmware/FirmwareUpdateCapabilities.ts +0 -9
  309. package/src/api/firmware/FirmwareUpdatePlan.ts +0 -772
  310. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +0 -449
  311. package/src/api/firmware/progressThrottle.ts +0 -44
  312. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  313. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
  314. package/src/types/api/firmwareUpdateCapabilities.ts +0 -9
  315. package/src/types/api/firmwareUpdatePlan.ts +0 -38
  316. 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';
@@ -34,7 +35,10 @@ import ProtocolInfoRequest from '../src/api/protocol-v2/ProtocolInfoRequest';
34
35
  import EVMSignTypedData from '../src/api/evm/EVMSignTypedData';
35
36
  import EVMSignMessageEIP712 from '../src/api/evm/EVMSignMessageEIP712';
36
37
  import FirmwareUpdateV3 from '../src/api/FirmwareUpdateV3';
37
- import FirmwareUpdateV4, { assertProtocolV2ReconnectIdentity } from '../src/api/FirmwareUpdateV4';
38
+ import FirmwareUpdateV4, {
39
+ assertProtocolV2FirmwareTargetsSupported,
40
+ assertProtocolV2ReconnectIdentity,
41
+ } from '../src/api/FirmwareUpdateV4';
38
42
  import GetPassphraseState from '../src/api/GetPassphraseState';
39
43
  import GetOnekeyFeatures from '../src/api/GetOnekeyFeatures';
40
44
  import { batchGetPublickeys } from '../src/api/helpers/batchGetPublickeys';
@@ -61,6 +65,7 @@ import { createCoreApi } from '../src/inject';
61
65
  import { Device, preloadSessionCache } from '../src/device/Device';
62
66
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
63
67
  import { DevicePool } from '../src/device/DevicePool';
68
+ import { DEVICE } from '../src/events';
64
69
  import { UI_REQUEST } from '../src/events/ui-request';
65
70
  import {
66
71
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -74,11 +79,15 @@ import {
74
79
  requestProtocolV2ProtocolInfo,
75
80
  supportsProtocolV2Message,
76
81
  } from '../src/protocols/protocol-v2/features';
82
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
77
83
  import {
78
84
  getProtocolV2WalletSession,
79
85
  refreshProtocolV2DeviceStatus,
80
86
  } from '../src/protocols/protocol-v2/walletSession';
81
- import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
87
+ import {
88
+ createProtocolV2UnlockContext,
89
+ runMethodWithUnlockPolicy,
90
+ } from '../src/protocols/protocol-v2/unlockPolicyRunner';
82
91
  import { BaseMethod } from '../src/api/BaseMethod';
83
92
  import {
84
93
  buildProtocolV1FeaturesPayload,
@@ -92,7 +101,6 @@ import {
92
101
  getMethodVersionRange,
93
102
  } from '../src/utils';
94
103
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
- import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
96
104
  import {
97
105
  getPassphraseState,
98
106
  getPassphraseStateWithRefreshDeviceInfo,
@@ -241,7 +249,7 @@ describe('UploadPortfolio', () => {
241
249
  (method as any).device = device;
242
250
 
243
251
  method.init();
244
- await runMethodWithUnlockRetry(method, device as any);
252
+ await runMethodWithUnlockPolicy(method, device as any);
245
253
 
246
254
  expect(unlockDevice).not.toHaveBeenCalled();
247
255
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -249,7 +257,10 @@ describe('UploadPortfolio', () => {
249
257
  'FilesystemFileWrite',
250
258
  'FilesystemFile',
251
259
  expect.any(Object),
252
- { timeoutMs: undefined }
260
+ expect.objectContaining({
261
+ timeoutMs: undefined,
262
+ onWriteCompleted: expect.any(Function),
263
+ })
253
264
  );
254
265
  expect(typedCall).toHaveBeenNthCalledWith(2, 'PortfolioUpdate', 'Success', {});
255
266
  });
@@ -292,7 +303,10 @@ describe('UploadPortfolio', () => {
292
303
  append: false,
293
304
  ui_percentage: 100,
294
305
  },
295
- { timeoutMs: undefined }
306
+ expect.objectContaining({
307
+ timeoutMs: undefined,
308
+ onWriteCompleted: expect.any(Function),
309
+ })
296
310
  );
297
311
  expect(typedCall).toHaveBeenNthCalledWith(2, 'PortfolioUpdate', 'Success', {});
298
312
  expect(method.postMessage).not.toHaveBeenCalled();
@@ -417,14 +431,16 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
417
431
  }
418
432
  return d.features;
419
433
  };
434
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
435
+ requestProtocolV2ProtocolInfo({
436
+ commands: d.getCommands?.() ?? d.commands,
437
+ timeoutMs,
438
+ });
420
439
  d.probeProtocolV2RuntimeState ??= async (
421
440
  deviceInfo: ProtocolV2DeviceInfo,
422
441
  timeoutMs?: number
423
442
  ) => {
424
- const protocolInfo = await requestProtocolV2ProtocolInfo({
425
- commands: d.getCommands(),
426
- timeoutMs,
427
- });
443
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
428
444
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
429
445
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
430
446
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -582,7 +598,7 @@ describe('Protocol V2 feature adapter', () => {
582
598
  } as any);
583
599
  const protocolV2 = buildProtocolV2FeaturesPayload({
584
600
  deviceInfo: {
585
- hw: { serial_no: 'P2-001' },
601
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'P2-001' },
586
602
  coprocessor: { bt_adv_name: 'Pro 2 BLE' },
587
603
  },
588
604
  deviceStatus: {
@@ -781,6 +797,9 @@ describe('Protocol V2 feature adapter', () => {
781
797
  if (request === 'DeviceStatusGet') {
782
798
  return { type: 'DeviceStatus', message: { unlocked: true } };
783
799
  }
800
+ if (request === 'DeviceSessionAskPassphrase') {
801
+ return { type: 'Success', message: {} };
802
+ }
784
803
  if (request === 'DeviceSessionGet') {
785
804
  return {
786
805
  type: 'DeviceSession',
@@ -805,7 +824,7 @@ describe('Protocol V2 feature adapter', () => {
805
824
  ).resolves.toEqual({
806
825
  passphraseState: 'state-1',
807
826
  newSession: 'session-1',
808
- unlockedAttachPin: undefined,
827
+ unlockedAttachPin: false,
809
828
  resumed: false,
810
829
  });
811
830
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -814,7 +833,13 @@ describe('Protocol V2 feature adapter', () => {
814
833
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
815
834
  type: DeviceSessionPinType.Main,
816
835
  });
817
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
836
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
837
+ passphrase: '',
838
+ on_device: false,
839
+ });
840
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
841
+ seed_domains: [],
842
+ });
818
843
  device.passphraseState = 'state-1';
819
844
  expect(device.getInternalState()).toBe('session-1');
820
845
  });
@@ -847,6 +872,7 @@ describe('Protocol V2 feature adapter', () => {
847
872
  },
848
873
  };
849
874
  }
875
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
850
876
  if (request === 'DeviceSessionGet') {
851
877
  return {
852
878
  message: {
@@ -863,9 +889,16 @@ describe('Protocol V2 feature adapter', () => {
863
889
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
864
890
 
865
891
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
866
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
892
+ expect(
893
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
894
+ ).toHaveLength(1);
895
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
896
+ seed_domains: [],
897
+ });
867
898
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
868
899
  session_id: 'standard-session',
900
+ btc_test_address: 'standard-state',
901
+ seed_domains: [],
869
902
  });
870
903
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
871
904
  passphraseState: 'standard-state',
@@ -907,6 +940,7 @@ describe('Protocol V2 feature adapter', () => {
907
940
  },
908
941
  };
909
942
  }
943
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
910
944
  if (request === 'DeviceSessionAskPin') {
911
945
  return { type: 'Success', message: { message: 'PIN verified' } };
912
946
  }
@@ -1001,6 +1035,7 @@ describe('Protocol V2 feature adapter', () => {
1001
1035
  if (request === 'DeviceStatusGet') {
1002
1036
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1003
1037
  }
1038
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1004
1039
  if (request === 'DeviceSessionGet') {
1005
1040
  sessionGetCalls += 1;
1006
1041
  return {
@@ -1227,7 +1262,9 @@ describe('Protocol V2 feature adapter', () => {
1227
1262
  passphrase: 'host hidden wallet',
1228
1263
  on_device: false,
1229
1264
  });
1230
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1265
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1266
+ seed_domains: [],
1267
+ });
1231
1268
  });
1232
1269
 
1233
1270
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1335,6 +1372,8 @@ describe('Protocol V2 feature adapter', () => {
1335
1372
 
1336
1373
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1337
1374
  session_id: 'session-a',
1375
+ btc_test_address: 'state-a',
1376
+ seed_domains: [],
1338
1377
  });
1339
1378
  expect(device.getInternalState()).toBe('session-b');
1340
1379
  });
@@ -1402,7 +1441,15 @@ describe('Protocol V2 feature adapter', () => {
1402
1441
  });
1403
1442
  expect(typedCall.mock.calls).toEqual([
1404
1443
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1405
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1444
+ [
1445
+ 'DeviceSessionGet',
1446
+ 'DeviceSession',
1447
+ {
1448
+ session_id: 'session-a',
1449
+ btc_test_address: 'state-a',
1450
+ seed_domains: [],
1451
+ },
1452
+ ],
1406
1453
  ]);
1407
1454
  expect(promptPassphrase).not.toHaveBeenCalled();
1408
1455
  expect(device.getInternalState()).toBeUndefined();
@@ -1450,7 +1497,7 @@ describe('Protocol V2 feature adapter', () => {
1450
1497
  { passphrase: 'host hidden wallet', on_device: false },
1451
1498
  ],
1452
1499
  ['DeviceStatusGet', 'DeviceStatus', {}],
1453
- ['DeviceSessionGet', 'DeviceSession', {}],
1500
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1454
1501
  ]);
1455
1502
  });
1456
1503
 
@@ -1553,6 +1600,73 @@ describe('Protocol V2 feature adapter', () => {
1553
1600
  expect(device.getInternalState()).toBeUndefined();
1554
1601
  });
1555
1602
 
1603
+ test.each([false, true])(
1604
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1605
+ async lockFails => {
1606
+ const deviceId = 'attach-mismatch-device';
1607
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1608
+ (device as any).features = normalizeProtocolV2Features(
1609
+ { ...descriptor, protocolType: 'V2' } as any,
1610
+ {
1611
+ status: {
1612
+ device_id: deviceId,
1613
+ unlocked: true,
1614
+ passphrase_enabled: true,
1615
+ attach_to_pin_enabled: true,
1616
+ unlocked_by_attach_to_pin: false,
1617
+ },
1618
+ }
1619
+ );
1620
+ device.passphraseState = 'expected-state';
1621
+
1622
+ const typedCall = jest.fn((request: string) => {
1623
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1624
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1625
+ if (request === 'DeviceStatusGet') {
1626
+ return {
1627
+ message: {
1628
+ device_id: deviceId,
1629
+ unlocked: true,
1630
+ passphrase_enabled: true,
1631
+ attach_to_pin_enabled: true,
1632
+ unlocked_by_attach_to_pin: true,
1633
+ },
1634
+ };
1635
+ }
1636
+ if (request === 'DeviceSessionGet') {
1637
+ return {
1638
+ message: {
1639
+ btc_test_address: 'wrong-attach-state',
1640
+ session_id: 'wrong-attach-session',
1641
+ },
1642
+ };
1643
+ }
1644
+ if (request === 'LockDevice') {
1645
+ if (lockFails) throw new Error('Lock unsupported');
1646
+ return { message: { message: 'Device locked' } };
1647
+ }
1648
+ throw new Error(`Unexpected request: ${request}`);
1649
+ });
1650
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1651
+ (device as any).commands = { typedCall, promptPassphrase };
1652
+
1653
+ await expect(
1654
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1655
+ ).rejects.toMatchObject({
1656
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1657
+ });
1658
+
1659
+ expect(promptPassphrase).not.toHaveBeenCalled();
1660
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1661
+ btc_test_address: 'expected-state',
1662
+ seed_domains: [],
1663
+ });
1664
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1665
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1666
+ expect(device.getInternalState()).toBeUndefined();
1667
+ }
1668
+ );
1669
+
1556
1670
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1557
1671
  const device = Device.fromDescriptor({
1558
1672
  id: 'cache-device-attach-recovery',
@@ -1609,7 +1723,17 @@ describe('Protocol V2 feature adapter', () => {
1609
1723
  }),
1610
1724
  { cancelDeviceOnReject: false }
1611
1725
  );
1612
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1726
+ expect(unlockDevice).toHaveBeenCalledWith(
1727
+ DeviceSessionPinType.AttachToPin,
1728
+ expect.objectContaining({
1729
+ emitUiEvent: false,
1730
+ interaction: expect.objectContaining({
1731
+ phase: 'pin',
1732
+ transition: 'start',
1733
+ protocol: 'V2',
1734
+ }),
1735
+ })
1736
+ );
1613
1737
  expect(device.getInternalState()).toBe('new-attach-session');
1614
1738
  });
1615
1739
 
@@ -1893,12 +2017,10 @@ describe('Protocol V2 feature adapter', () => {
1893
2017
  expect(getFeatures).not.toHaveBeenCalled();
1894
2018
  });
1895
2019
 
1896
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
2020
+ test('uses the cached wallet address when the local session is missing', async () => {
1897
2021
  const typedCall = jest
1898
2022
  .fn()
1899
2023
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1902
2024
  .mockResolvedValueOnce({
1903
2025
  message: {
1904
2026
  btc_test_address: 'expected-state',
@@ -1926,16 +2048,11 @@ describe('Protocol V2 feature adapter', () => {
1926
2048
  await expect(
1927
2049
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1928
2050
  ).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
- );
2051
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2052
+ btc_test_address: 'expected-state',
2053
+ seed_domains: [],
2054
+ });
2055
+ expect(promptPassphrase).not.toHaveBeenCalled();
1939
2056
  });
1940
2057
 
1941
2058
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2001,8 +2118,15 @@ describe('Protocol V2 feature adapter', () => {
2001
2118
  true
2002
2119
  );
2003
2120
  expect(typedCall.mock.calls).toEqual([
2004
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2121
+ [
2122
+ 'DeviceSessionGet',
2123
+ 'DeviceSession',
2124
+ {
2125
+ session_id: 'session-pro2-app',
2126
+ btc_test_address: 'state-pro2-app',
2127
+ seed_domains: [],
2128
+ },
2129
+ ],
2006
2130
  ]);
2007
2131
  });
2008
2132
 
@@ -2191,7 +2315,9 @@ describe('Protocol V2 feature adapter', () => {
2191
2315
  passphrase: 'host hidden wallet',
2192
2316
  on_device: false,
2193
2317
  });
2194
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2318
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2319
+ seed_domains: [],
2320
+ });
2195
2321
  });
2196
2322
 
2197
2323
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2269,12 +2395,83 @@ describe('Protocol V2 feature adapter', () => {
2269
2395
  );
2270
2396
 
2271
2397
  expect(device.getInternalState()).toBeUndefined();
2272
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2398
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2399
+ btc_test_address: 'expected-state',
2400
+ seed_domains: [],
2401
+ });
2402
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2273
2403
  passphrase: 'host hidden wallet',
2274
2404
  on_device: false,
2275
2405
  });
2276
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2406
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2407
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2408
+ seed_domains: [],
2409
+ });
2410
+ });
2411
+
2412
+ test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
2413
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2414
+ const typedCall = jest.fn((request: string) => {
2415
+ if (request === 'ProtocolInfoRequest') {
2416
+ return { message: { version: 2 } };
2417
+ }
2418
+ if (request === 'DeviceStatusGet') {
2419
+ return {
2420
+ message: {
2421
+ device_id: 'attach-wallet-device',
2422
+ unlocked: true,
2423
+ passphrase_enabled: true,
2424
+ attach_to_pin_enabled: true,
2425
+ unlocked_by_attach_to_pin: true,
2426
+ },
2427
+ };
2428
+ }
2429
+ if (request === 'DeviceSessionAskPin' || request === 'DeviceSessionAskPassphrase') {
2430
+ return { message: { message: 'Wallet selected' } };
2431
+ }
2432
+ if (request === 'DeviceSessionGet') {
2433
+ return {
2434
+ message: {
2435
+ session_id: 'main-wallet-session',
2436
+ btc_test_address: 'main-wallet-state',
2437
+ },
2438
+ };
2439
+ }
2440
+ if (request === 'LockDevice') {
2441
+ return { message: { message: 'Device locked' } };
2442
+ }
2443
+ throw new Error(`Unexpected request: ${request}`);
2444
+ });
2445
+
2446
+ (device as any).features = normalizeProtocolV2Features(
2447
+ { ...descriptor, protocolType: 'V2' } as any,
2448
+ {
2449
+ status: {
2450
+ device_id: 'attach-wallet-device',
2451
+ unlocked: true,
2452
+ passphrase_enabled: true,
2453
+ attach_to_pin_enabled: true,
2454
+ // 模拟缓存滞后:设备状态刷新后才确认当前由 Attach PIN 解锁。
2455
+ unlocked_by_attach_to_pin: false,
2456
+ },
2457
+ }
2458
+ );
2459
+ (device as any).commands = { typedCall };
2460
+ const clearInternalState = jest.spyOn(device, 'clearInternalState');
2461
+
2462
+ await expect(device.checkPassphraseStateSafety(undefined, true, false)).rejects.toMatchObject({
2463
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2464
+ });
2465
+
2466
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2467
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
2468
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2469
+ expect(typedCall).not.toHaveBeenCalledWith(
2470
+ 'DeviceSessionAskPassphrase',
2471
+ 'Success',
2472
+ expect.anything()
2473
+ );
2474
+ expect(clearInternalState).toHaveBeenCalledTimes(1);
2278
2475
  });
2279
2476
 
2280
2477
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2307,10 +2504,22 @@ describe('Protocol V2 feature adapter', () => {
2307
2504
  await expect(
2308
2505
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2309
2506
  ).resolves.toBe(true);
2310
- expect(typedCall).toHaveBeenCalledTimes(4);
2507
+ expect(typedCall).toHaveBeenCalledTimes(5);
2311
2508
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2312
2509
  eventless_wallet_session: true,
2313
2510
  });
2511
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2512
+ seed_domains: [],
2513
+ });
2514
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2515
+ passphrase: '',
2516
+ on_device: false,
2517
+ });
2518
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2519
+ expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
2520
+ 2
2521
+ );
2522
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2314
2523
  });
2315
2524
 
2316
2525
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2381,7 +2590,7 @@ describe('Protocol V2 feature adapter', () => {
2381
2590
  uuid: 'CACHED-SERIAL',
2382
2591
  deviceId: null,
2383
2592
  bleName: 'Cached BLE',
2384
- name: 'Cached Label',
2593
+ name: 'Cached BLE',
2385
2594
  label: 'Cached Label',
2386
2595
  deviceType: 'pro2',
2387
2596
  });
@@ -2408,7 +2617,7 @@ describe('Protocol V2 feature adapter', () => {
2408
2617
  expect(device.features?.label).toBeNull();
2409
2618
  });
2410
2619
 
2411
- test('prefers the real Protocol V2 label over the BLE name for display', () => {
2620
+ test('uses the BLE name for discovery while preserving the Protocol V2 label', () => {
2412
2621
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2413
2622
  (device as any).features = {
2414
2623
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
@@ -2416,9 +2625,10 @@ describe('Protocol V2 feature adapter', () => {
2416
2625
  bleName: 'Pro2 6136',
2417
2626
  };
2418
2627
 
2628
+ expect(device.getCurrentDisplayName()).toBe('My Pro 2');
2419
2629
  expect(device.toMessageObject()).toMatchObject({
2420
2630
  bleName: 'Pro2 6136',
2421
- name: 'My Pro 2',
2631
+ name: 'Pro2 6136',
2422
2632
  label: 'My Pro 2',
2423
2633
  });
2424
2634
  });
@@ -2472,10 +2682,6 @@ describe('Protocol V2 feature adapter', () => {
2472
2682
  hw: true,
2473
2683
  fw: true,
2474
2684
  coprocessor: true,
2475
- se1: true,
2476
- se2: true,
2477
- se3: true,
2478
- se4: true,
2479
2685
  },
2480
2686
  types: {
2481
2687
  version: true,
@@ -2499,8 +2705,10 @@ describe('Protocol V2 feature adapter', () => {
2499
2705
  ).rejects.toThrow('DeviceInfo not supported');
2500
2706
  });
2501
2707
 
2502
- test('does not inherit Pro or Pro model fallback ranges for Protocol V2 devices', () => {
2503
- const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any);
2708
+ test('does not inherit Pro or model_touch ranges for Protocol V2 devices', () => {
2709
+ const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2710
+ hw: { Device_type: DeviceType.PRO2 },
2711
+ });
2504
2712
  const checkedTypes: string[] = [];
2505
2713
 
2506
2714
  const versionRange = getMethodVersionRange(features, type => {
@@ -2512,11 +2720,13 @@ describe('Protocol V2 feature adapter', () => {
2512
2720
  });
2513
2721
 
2514
2722
  expect(versionRange).toBeUndefined();
2515
- expect(checkedTypes).toEqual(['pro2']);
2723
+ expect(checkedTypes).toEqual(['pro2', 'model_pro2']);
2516
2724
  });
2517
2725
 
2518
2726
  test('uses firmware-v1 as the Pro2 remote firmware config field', () => {
2519
- const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any);
2727
+ const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2728
+ hw: { Device_type: DeviceType.PRO2 },
2729
+ });
2520
2730
 
2521
2731
  expect(
2522
2732
  getFirmwareUpdateField({
@@ -2667,10 +2877,6 @@ describe('Protocol V2 feature adapter', () => {
2667
2877
  hw: true,
2668
2878
  fw: true,
2669
2879
  coprocessor: true,
2670
- se1: true,
2671
- se2: true,
2672
- se3: true,
2673
- se4: true,
2674
2880
  },
2675
2881
  types: {
2676
2882
  version: true,
@@ -2681,7 +2887,9 @@ describe('Protocol V2 feature adapter', () => {
2681
2887
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2682
2888
  }
2683
2889
  );
2684
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2890
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2891
+ eventless_wallet_session: true,
2892
+ });
2685
2893
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2686
2894
  });
2687
2895
 
@@ -2719,7 +2927,9 @@ describe('Protocol V2 feature adapter', () => {
2719
2927
  }),
2720
2928
  expect.anything()
2721
2929
  );
2722
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2930
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2931
+ eventless_wallet_session: true,
2932
+ });
2723
2933
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2724
2934
  expect(device.features).toMatchObject({
2725
2935
  deviceId: null,
@@ -2985,10 +3195,6 @@ describe('Protocol V2 feature adapter', () => {
2985
3195
  fw: { application: { version: '1.2.4' } },
2986
3196
  },
2987
3197
  })
2988
- .mockResolvedValueOnce({
2989
- type: 'ProtocolInfo',
2990
- message: protocolV2ApplicationInfo,
2991
- })
2992
3198
  .mockResolvedValueOnce({
2993
3199
  type: 'DeviceStatus',
2994
3200
  message: { init_states: true, unlocked: true },
@@ -3010,7 +3216,7 @@ describe('Protocol V2 feature adapter', () => {
3010
3216
  firmwareVersion: '1.2.4',
3011
3217
  passphraseProtection: null,
3012
3218
  });
3013
- expect(typedCall).toHaveBeenCalledTimes(6);
3219
+ expect(typedCall).toHaveBeenCalledTimes(5);
3014
3220
  expect(typedCall).toHaveBeenNthCalledWith(
3015
3221
  4,
3016
3222
  'DeviceInfoGet',
@@ -3020,10 +3226,6 @@ describe('Protocol V2 feature adapter', () => {
3020
3226
  hw: true,
3021
3227
  fw: true,
3022
3228
  coprocessor: true,
3023
- se1: true,
3024
- se2: true,
3025
- se3: true,
3026
- se4: true,
3027
3229
  },
3028
3230
  types: {
3029
3231
  version: true,
@@ -3124,9 +3326,23 @@ describe('Protocol V2 feature adapter', () => {
3124
3326
  status: { init_states: true },
3125
3327
  }
3126
3328
  );
3329
+ const pinOnDevice = jest.fn();
3330
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3127
3331
 
3128
3332
  const features = await device.unlockDevice();
3129
3333
 
3334
+ expect(pinOnDevice).toHaveBeenCalledWith(
3335
+ device,
3336
+ DeviceSessionPinType.Main,
3337
+ expect.objectContaining({
3338
+ source: 'unlock-coordinator',
3339
+ reason: 'device-unlock',
3340
+ deviceOnly: true,
3341
+ })
3342
+ );
3343
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3344
+ typedCall.mock.invocationCallOrder[0]
3345
+ );
3130
3346
  expect(typedCall.mock.calls).toEqual([
3131
3347
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3132
3348
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3172,9 +3388,12 @@ describe('Protocol V2 feature adapter', () => {
3172
3388
  throw new Error(`Unexpected request: ${requestType}`);
3173
3389
  });
3174
3390
  (device as any).commands = { typedCall };
3391
+ const pinOnDevice = jest.fn();
3392
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3175
3393
 
3176
- await device.unlockDevice();
3394
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3177
3395
 
3396
+ expect(pinOnDevice).not.toHaveBeenCalled();
3178
3397
  expect(typedCall.mock.calls).toEqual([
3179
3398
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3180
3399
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3417,71 +3636,77 @@ describe('API compatibility handling', () => {
3417
3636
  });
3418
3637
  });
3419
3638
 
3420
- test('allows Pro2 Solana signing methods through Protocol V2 version checks', () => {
3421
- const features = {
3422
- deviceType: 'pro2',
3423
- } as Features;
3639
+ test.each(['pro2', 'neo'] as const)(
3640
+ 'allows %s Solana signing methods through Protocol V2 version checks',
3641
+ deviceType => {
3642
+ const features = {
3643
+ deviceType,
3644
+ } as Features;
3424
3645
 
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
- });
3646
+ const solSignMessageMethod = new SolSignMessage({
3647
+ id: 1,
3648
+ payload: {
3649
+ method: 'solSignMessage',
3650
+ path: "m/44'/501'/0'/0'",
3651
+ messageHex: '48656c6c6f',
3652
+ },
3653
+ });
3654
+ const solSignOffchainMessageMethod = new SolSignOffchainMessage({
3655
+ id: 2,
3656
+ payload: {
3657
+ method: 'solSignOffchainMessage',
3658
+ path: "m/44'/501'/0'/0'",
3659
+ messageHex: '48656c6c6f',
3660
+ },
3661
+ });
3662
+ const solSignTransactionMethod = new SolSignTransaction({
3663
+ id: 3,
3664
+ payload: {
3665
+ method: 'solSignTransaction',
3666
+ path: "m/44'/501'/0'/0'",
3667
+ rawTx: '00',
3668
+ },
3669
+ });
3670
+ const solSignVersionedTransactionMethod = new SolSignTransaction({
3671
+ id: 4,
3672
+ payload: {
3673
+ method: 'solSignTransaction',
3674
+ path: "m/44'/501'/0'/0'",
3675
+ rawTx: '80',
3676
+ },
3677
+ });
3457
3678
 
3458
- solSignTransactionMethod.init();
3459
- solSignVersionedTransactionMethod.init();
3679
+ solSignTransactionMethod.init();
3680
+ solSignVersionedTransactionMethod.init();
3460
3681
 
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
- });
3682
+ expect(
3683
+ getMethodVersionRange(features, type => solSignMessageMethod.getVersionRange()[type])
3684
+ ).toEqual({
3685
+ min: '0.0.0',
3686
+ });
3687
+ expect(
3688
+ getMethodVersionRange(
3689
+ features,
3690
+ type => solSignOffchainMessageMethod.getVersionRange()[type]
3691
+ )
3692
+ ).toEqual({
3693
+ min: '0.0.0',
3694
+ });
3695
+ expect(
3696
+ getMethodVersionRange(features, type => solSignTransactionMethod.getVersionRange()[type])
3697
+ ).toEqual({
3698
+ min: '0.0.0',
3699
+ });
3700
+ expect(
3701
+ getMethodVersionRange(
3702
+ features,
3703
+ type => solSignVersionedTransactionMethod.getVersionRange()[type]
3704
+ )
3705
+ ).toEqual({
3706
+ min: '0.0.0',
3707
+ });
3708
+ }
3709
+ );
3485
3710
 
3486
3711
  test('uses chunk transfer for large Sui transactions on Protocol V2', async () => {
3487
3712
  const rawTx = '0x'.concat('ab'.repeat(5000));
@@ -3577,6 +3802,15 @@ describe('API compatibility handling', () => {
3577
3802
 
3578
3803
  describe('Protocol V2 firmware update targets', () => {
3579
3804
  const OKPP_HEADER_SIZE = 0x52a0;
3805
+ let forceReloadDataSpy: jest.SpyInstance;
3806
+
3807
+ beforeEach(() => {
3808
+ forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3809
+ });
3810
+
3811
+ afterEach(() => {
3812
+ jest.restoreAllMocks();
3813
+ });
3580
3814
 
3581
3815
  const buildOkppHeader = ({
3582
3816
  type = 'RESC',
@@ -3701,7 +3935,7 @@ describe('Protocol V2 firmware update targets', () => {
3701
3935
  specific: true,
3702
3936
  },
3703
3937
  },
3704
- { timeoutMs: 5000 }
3938
+ { timeoutMs: 30000 }
3705
3939
  );
3706
3940
  expect(typedCall).toHaveBeenCalledTimes(3);
3707
3941
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
@@ -3717,7 +3951,7 @@ describe('Protocol V2 firmware update targets', () => {
3717
3951
  });
3718
3952
  });
3719
3953
 
3720
- test('delegates Protocol V2 mode transition to the phase executor', async () => {
3954
+ test('enters Protocol V2 bootloader before upload', async () => {
3721
3955
  const method = new FirmwareUpdateV4({
3722
3956
  id: 1,
3723
3957
  payload: {
@@ -3752,7 +3986,7 @@ describe('Protocol V2 firmware update targets', () => {
3752
3986
 
3753
3987
  await method.run();
3754
3988
 
3755
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3989
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3756
3990
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3757
3991
  fwBinaryMap: [
3758
3992
  {
@@ -3763,19 +3997,95 @@ describe('Protocol V2 firmware update targets', () => {
3763
3997
  ],
3764
3998
  bootloaderBinary: null,
3765
3999
  });
4000
+ expect(method.postTipMessage).toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
3766
4001
  });
3767
4002
 
3768
- test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4003
+ test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
3769
4004
  const method = new FirmwareUpdateV4({
3770
4005
  id: 1,
3771
4006
  payload: {
3772
4007
  method: 'firmwareUpdateV4',
4008
+ platform: 'web',
4009
+ targetsToUpdate: ['resource'],
3773
4010
  },
3774
4011
  });
4012
+ method.init();
4013
+
3775
4014
  (method as any).device = stubDevice({
3776
- originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
3777
- features: { bootloader_mode: false, capabilities: [] },
3778
- isBootloader: () => false,
4015
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4016
+ features: {
4017
+ deviceType: 'pro2',
4018
+ firmwareVersion: '1.0.0',
4019
+ mode: 'normal',
4020
+ bootloaderMode: false,
4021
+ capabilities: [],
4022
+ },
4023
+ isBootloader: () => false,
4024
+ isRomloader: () => false,
4025
+ });
4026
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4027
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
4028
+ bootloaderBinary: null,
4029
+ fwBinaryMap: [],
4030
+ installItems: [],
4031
+ });
4032
+
4033
+ const order: string[] = [];
4034
+ (method as any).prepareProtocolV2BootResources = jest.fn().mockImplementation(() => {
4035
+ order.push('prepare-startup-resources');
4036
+ return Promise.resolve([]);
4037
+ });
4038
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4039
+ order.push('enter-bootloader');
4040
+ return Promise.resolve(true);
4041
+ });
4042
+ (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
4043
+ order.push('prepare-resources');
4044
+ return Promise.resolve([
4045
+ {
4046
+ name: 'images.okpkg',
4047
+ binary: new Uint8Array([1]).buffer,
4048
+ devicePath: 'vol0:/bundles/images/images.okpkg',
4049
+ },
4050
+ ]);
4051
+ });
4052
+ (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4053
+ order.push('execute-update');
4054
+ return Promise.resolve();
4055
+ });
4056
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4057
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4058
+ bootloaderVersion: '1.0.0',
4059
+ bleVersion: '1.0.0',
4060
+ firmwareVersion: '1.0.0',
4061
+ });
4062
+ method.postTipMessage = jest.fn();
4063
+
4064
+ await method.run();
4065
+
4066
+ expect(order).toEqual([
4067
+ 'prepare-startup-resources',
4068
+ 'enter-bootloader',
4069
+ 'prepare-resources',
4070
+ 'execute-update',
4071
+ ]);
4072
+ });
4073
+
4074
+ test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4075
+ const method = new FirmwareUpdateV4({
4076
+ id: 1,
4077
+ payload: {
4078
+ method: 'firmwareUpdateV4',
4079
+ },
4080
+ });
4081
+ (method as any).device = stubDevice({
4082
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4083
+ features: { bootloader_mode: false, capabilities: [] },
4084
+ isBootloader: () => false,
4085
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4086
+ mode: 'bootloader',
4087
+ bootloaderMode: true,
4088
+ }),
3779
4089
  });
3780
4090
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
3781
4091
  message: 'Device rebooted successfully',
@@ -3803,6 +4113,7 @@ describe('Protocol V2 firmware update targets', () => {
3803
4113
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3804
4114
  (method as any).device.getCommands = () => ({ typedCall });
3805
4115
  (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4116
+ (method as any).protocolV2ExpectedPath = 'usb-path';
3806
4117
  method.postTipMessage = jest.fn();
3807
4118
 
3808
4119
  await (method as any).enterProtocolV2BootloaderMode();
@@ -3814,6 +4125,66 @@ describe('Protocol V2 firmware update targets', () => {
3814
4125
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3815
4126
  });
3816
4127
 
4128
+ test('announces bootloader success only after Protocol V2 bootloader mode is confirmed', async () => {
4129
+ const method = new FirmwareUpdateV4({
4130
+ id: 1,
4131
+ payload: {
4132
+ method: 'firmwareUpdateV4',
4133
+ },
4134
+ });
4135
+ (method as any).device = stubDevice({
4136
+ features: { bootloader_mode: false, capabilities: [] },
4137
+ isBootloader: () => false,
4138
+ isRomloader: () => false,
4139
+ });
4140
+ (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4141
+ message: 'Device rebooted successfully',
4142
+ });
4143
+
4144
+ const tipMessages: string[] = [];
4145
+ method.postTipMessage = jest.fn((message: string) => {
4146
+ tipMessages.push(message);
4147
+ });
4148
+ let successWasAnnouncedBeforeConfirmation = false;
4149
+ (method as any).waitForProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4150
+ successWasAnnouncedBeforeConfirmation = tipMessages.includes('GoToBootloaderSuccess');
4151
+ return Promise.resolve({ mode: 'bootloader' });
4152
+ });
4153
+
4154
+ await (method as any).enterProtocolV2BootloaderMode();
4155
+
4156
+ expect(successWasAnnouncedBeforeConfirmation).toBe(false);
4157
+ expect(tipMessages).toEqual(['AutoRebootToBootloader', 'GoToBootloaderSuccess']);
4158
+ });
4159
+
4160
+ test('keeps Protocol V2 bootloader active before firmware transfer', async () => {
4161
+ const method = new FirmwareUpdateV4({
4162
+ id: 1,
4163
+ payload: {
4164
+ method: 'firmwareUpdateV4',
4165
+ },
4166
+ });
4167
+ (method as any).device = stubDevice({
4168
+ originalDescriptor: { id: 'usb-id', path: 'bootloader-path', protocolType: 'V2' },
4169
+ features: {
4170
+ deviceType: 'pro2',
4171
+ mode: 'bootloader',
4172
+ bootloaderMode: true,
4173
+ capabilities: [],
4174
+ },
4175
+ isBootloader: () => true,
4176
+ isRomloader: () => false,
4177
+ });
4178
+ (method as any).protocolV2Reboot = jest.fn();
4179
+ method.postTipMessage = jest.fn();
4180
+
4181
+ await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4182
+
4183
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4184
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
4185
+ expect(method.postTipMessage).toHaveBeenCalledWith('GoToBootloaderSuccess');
4186
+ });
4187
+
3817
4188
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
3818
4189
  const method = new FirmwareUpdateV4({
3819
4190
  id: 1,
@@ -3866,6 +4237,9 @@ describe('Protocol V2 firmware update targets', () => {
3866
4237
  },
3867
4238
  });
3868
4239
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4240
+ const prepareResourceBundles = jest
4241
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4242
+ .mockResolvedValue(undefined);
3869
4243
  (method as any).protocolV2Reboot = jest.fn();
3870
4244
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3871
4245
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -3878,6 +4252,7 @@ describe('Protocol V2 firmware update targets', () => {
3878
4252
 
3879
4253
  await method.run();
3880
4254
 
4255
+ expect(prepareResourceBundles).not.toHaveBeenCalled();
3881
4256
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3882
4257
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3883
4258
  bootloaderBinary: null,
@@ -3897,6 +4272,10 @@ describe('Protocol V2 firmware update targets', () => {
3897
4272
  originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
3898
4273
  features: { bootloader_mode: false, capabilities: [] },
3899
4274
  isBootloader: () => false,
4275
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4276
+ mode: 'bootloader',
4277
+ bootloaderMode: true,
4278
+ }),
3900
4279
  });
3901
4280
  const typedCall = jest.fn().mockImplementation((requestType: string) => {
3902
4281
  if (requestType === 'DeviceInfoGet') {
@@ -3924,15 +4303,48 @@ describe('Protocol V2 firmware update targets', () => {
3924
4303
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3925
4304
  (method as any).device.getCommands = () => ({ typedCall });
3926
4305
  (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4306
+ (method as any).protocolV2ExpectedPath = 'app-path';
3927
4307
 
3928
4308
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
3929
4309
 
3930
4310
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4311
+ expect(typedCall).toHaveBeenCalledTimes(1);
4312
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4313
+ });
4314
+
4315
+ test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
4316
+ const method = new FirmwareUpdateV4({
4317
+ id: 1,
4318
+ payload: {
4319
+ method: 'firmwareUpdateV4',
4320
+ },
4321
+ });
4322
+ const typedCall = jest
4323
+ .fn()
4324
+ .mockResolvedValueOnce({
4325
+ type: 'DeviceInfo',
4326
+ message: { protocol_version: 1, hw: {} },
4327
+ })
4328
+ .mockResolvedValueOnce({
4329
+ type: 'DeviceInfo',
4330
+ message: protocolV2BootloaderDeviceInfo,
4331
+ });
4332
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4333
+ (method as any).device = stubDevice({
4334
+ originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
4335
+ getCommands: () => ({ typedCall }),
4336
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4337
+ mode: 'bootloader',
4338
+ bootloaderMode: true,
4339
+ }),
4340
+ });
4341
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4342
+ (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4343
+
4344
+ await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
4345
+
4346
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
3931
4347
  expect(typedCall).toHaveBeenCalledTimes(2);
3932
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3933
- 'DeviceInfoGet',
3934
- 'ProtocolInfoRequest',
3935
- ]);
3936
4348
  });
3937
4349
 
3938
4350
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -3960,12 +4372,16 @@ describe('Protocol V2 firmware update targets', () => {
3960
4372
  getCommands: () => commands,
3961
4373
  mainId: 'usb-path',
3962
4374
  });
3963
- const cachedDevice = { getConnectId: () => 'usb-path' };
4375
+ const cachedDevice = {
4376
+ getConnectId: () => 'usb-path',
4377
+ getCurrentSerialNo: () => '',
4378
+ };
3964
4379
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
3965
4380
  devices: {},
3966
4381
  deviceList: [cachedDevice],
3967
4382
  } as any);
3968
4383
  (method as any).device = device;
4384
+ (method as any).protocolV2ExpectedPath = 'usb-path';
3969
4385
 
3970
4386
  try {
3971
4387
  await (method as any).reconnectProtocolV2Device();
@@ -3977,133 +4393,335 @@ describe('Protocol V2 firmware update targets', () => {
3977
4393
  expect(initialize).not.toHaveBeenCalled();
3978
4394
  });
3979
4395
 
3980
- test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4396
+ test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
3981
4397
  const method = new FirmwareUpdateV4({
3982
4398
  id: 1,
3983
4399
  payload: {
3984
4400
  method: 'firmwareUpdateV4',
3985
4401
  },
3986
4402
  });
3987
- const commands = { disposed: false, mainId: 'usb-session' };
3988
- const acquire = jest.fn().mockResolvedValue(undefined);
4403
+ const commands = { disposed: true, mainId: '' };
4404
+ const updateFromCache = jest.fn();
3989
4405
  const device = stubDevice({
3990
- originalDescriptor: {
3991
- id: 'usb-id',
3992
- path: 'usb-path',
3993
- session: 'usb-session',
3994
- protocolType: 'V2',
3995
- },
4406
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
3996
4407
  deviceConnector: {
3997
4408
  enumerate: jest.fn().mockResolvedValue({
3998
4409
  descriptors: [
3999
- {
4000
- id: 'usb-id',
4001
- path: 'usb-path',
4002
- session: 'usb-session',
4003
- protocolType: 'V2',
4004
- },
4410
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4411
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4005
4412
  ],
4006
4413
  }),
4007
4414
  },
4008
- updateDescriptor: jest.fn(),
4009
- updateFromCache: jest.fn(),
4010
- hasDeviceAcquire: jest.fn(() => true),
4011
- acquire,
4415
+ updateFromCache,
4416
+ hasDeviceAcquire: jest.fn(() => false),
4417
+ acquire: jest.fn().mockResolvedValue(undefined),
4012
4418
  commands,
4013
4419
  getCommands: () => commands,
4014
- mainId: 'usb-session',
4420
+ mainId: 'old-usb-path',
4015
4421
  });
4422
+ const otherDevice = {
4423
+ getConnectId: () => 'usb-a',
4424
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4425
+ };
4426
+ const expectedDevice = {
4427
+ getConnectId: () => 'usb-b',
4428
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4429
+ };
4016
4430
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4017
4431
  devices: {},
4018
- deviceList: [{ getConnectId: () => 'usb-path' }],
4432
+ deviceList: [otherDevice, expectedDevice],
4019
4433
  } as any);
4020
4434
  (method as any).device = device;
4435
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4436
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4021
4437
 
4022
4438
  try {
4023
4439
  await (method as any).reconnectProtocolV2Device();
4024
- await (method as any).reconnectProtocolV2Device();
4025
4440
  } finally {
4026
4441
  getDevices.mockRestore();
4027
4442
  }
4028
4443
 
4029
- expect(acquire).not.toHaveBeenCalled();
4030
- expect(device.hasDeviceAcquire).toHaveBeenCalledTimes(2);
4444
+ expect(updateFromCache).toHaveBeenCalledWith(expectedDevice);
4031
4445
  });
4032
4446
 
4033
- test('stops final reconnect polling immediately when firmware changes device identity', async () => {
4447
+ test('rejects ambiguous Protocol V2 USB reconnect candidates without an identity match', async () => {
4034
4448
  const method = new FirmwareUpdateV4({
4035
4449
  id: 1,
4036
4450
  payload: {
4037
4451
  method: 'firmwareUpdateV4',
4038
4452
  },
4039
4453
  });
4040
- const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4041
- const typedCall = jest.fn().mockResolvedValue({
4042
- type: 'DeviceInfo',
4043
- message: {
4044
- hw: { serial_no: 'PR2SERIAL' },
4045
- fw: { application: { version: '1.0.0' } },
4454
+ const updateFromCache = jest.fn();
4455
+ const device = stubDevice({
4456
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4457
+ deviceConnector: {
4458
+ enumerate: jest.fn().mockResolvedValue({
4459
+ descriptors: [
4460
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4461
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4462
+ ],
4463
+ }),
4046
4464
  },
4465
+ updateFromCache,
4047
4466
  });
4048
- (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
4049
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4050
- (method as any).device = stubDevice({
4051
- getCommands: () => ({ typedCall }),
4052
- probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4053
- deviceId: 'changed-device',
4054
- mode: 'normal',
4055
- bootloaderMode: false,
4056
- }),
4057
- });
4467
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4468
+ devices: {},
4469
+ deviceList: [
4470
+ {
4471
+ getConnectId: () => 'usb-a',
4472
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4473
+ },
4474
+ {
4475
+ getConnectId: () => 'usb-b',
4476
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4477
+ },
4478
+ ],
4479
+ } as any);
4480
+ (method as any).device = device;
4481
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-C';
4058
4482
 
4059
- await expect((method as any).waitForProtocolV2ReconnectAndFeatures(60_000)).rejects.toThrow(
4060
- 'identity mismatch'
4061
- );
4062
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4483
+ try {
4484
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4485
+ } finally {
4486
+ getDevices.mockRestore();
4487
+ }
4488
+
4489
+ expect(updateFromCache).not.toHaveBeenCalled();
4063
4490
  });
4064
4491
 
4065
- test('reboots Protocol V2 firmware flow back to normal without legacy switch-firmware prompt', async () => {
4492
+ test('allows a Protocol V2 USB reconnect candidate without a serial only when its path matches', async () => {
4066
4493
  const method = new FirmwareUpdateV4({
4067
4494
  id: 1,
4068
4495
  payload: {
4069
4496
  method: 'firmwareUpdateV4',
4070
4497
  },
4071
4498
  });
4072
- method.postTipMessage = jest.fn();
4073
- (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4074
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4075
- (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4076
- message: 'Device rebooted successfully',
4499
+ const commands = { disposed: true, mainId: '' };
4500
+ const updateFromCache = jest.fn();
4501
+ const device = stubDevice({
4502
+ originalDescriptor: { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4503
+ deviceConnector: {
4504
+ enumerate: jest.fn().mockResolvedValue({
4505
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4506
+ }),
4507
+ },
4508
+ updateFromCache,
4509
+ hasDeviceAcquire: jest.fn(() => false),
4510
+ acquire: jest.fn().mockResolvedValue(undefined),
4511
+ commands,
4512
+ getCommands: () => commands,
4513
+ mainId: 'old-usb-path',
4077
4514
  });
4515
+ const candidate = {
4516
+ getConnectId: () => 'usb-a',
4517
+ getCurrentSerialNo: () => '',
4518
+ };
4519
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4520
+ devices: {},
4521
+ deviceList: [candidate],
4522
+ } as any);
4523
+ (method as any).device = device;
4524
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-A';
4525
+ (method as any).protocolV2ExpectedPath = 'usb-a';
4078
4526
 
4079
- await (method as any).exitProtocolV2BootloaderToNormal();
4527
+ try {
4528
+ await (method as any).reconnectProtocolV2Device();
4529
+ } finally {
4530
+ getDevices.mockRestore();
4531
+ }
4080
4532
 
4081
- expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4082
- expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4083
- expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4533
+ expect(updateFromCache).toHaveBeenCalledWith(candidate);
4084
4534
  });
4085
4535
 
4086
- test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4536
+ test('rejects a single Protocol V2 USB reconnect candidate with a different physical serial', async () => {
4087
4537
  const method = new FirmwareUpdateV4({
4088
4538
  id: 1,
4089
4539
  payload: {
4090
4540
  method: 'firmwareUpdateV4',
4091
4541
  },
4092
4542
  });
4093
- const typedCall = jest
4094
- .fn()
4095
- .mockRejectedValue(
4096
- new Error("The operation couldn't be completed. (MultiplatformBleAdapter.RxError error 6.)")
4097
- );
4098
-
4099
- (method as any).device = stubDevice({
4100
- getCommands: () => ({ typedCall }),
4101
- });
4102
-
4103
- await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
4104
- message: 'Device rebooted successfully',
4543
+ const updateFromCache = jest.fn();
4544
+ const device = stubDevice({
4545
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4546
+ deviceConnector: {
4547
+ enumerate: jest.fn().mockResolvedValue({
4548
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4549
+ }),
4550
+ },
4551
+ updateFromCache,
4105
4552
  });
4106
- });
4553
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4554
+ devices: {},
4555
+ deviceList: [
4556
+ {
4557
+ getConnectId: () => 'usb-a',
4558
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4559
+ },
4560
+ ],
4561
+ } as any);
4562
+ (method as any).device = device;
4563
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4564
+
4565
+ try {
4566
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4567
+ } finally {
4568
+ getDevices.mockRestore();
4569
+ }
4570
+
4571
+ expect(updateFromCache).not.toHaveBeenCalled();
4572
+ });
4573
+
4574
+ test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4575
+ const method = new FirmwareUpdateV4({
4576
+ id: 1,
4577
+ payload: {
4578
+ method: 'firmwareUpdateV4',
4579
+ },
4580
+ });
4581
+ const commands = { disposed: false, mainId: 'usb-session' };
4582
+ const acquire = jest.fn().mockResolvedValue(undefined);
4583
+ const device = stubDevice({
4584
+ originalDescriptor: {
4585
+ id: 'usb-id',
4586
+ path: 'usb-path',
4587
+ session: 'usb-session',
4588
+ protocolType: 'V2',
4589
+ },
4590
+ deviceConnector: {
4591
+ enumerate: jest.fn().mockResolvedValue({
4592
+ descriptors: [
4593
+ {
4594
+ id: 'usb-id',
4595
+ path: 'usb-path',
4596
+ session: 'usb-session',
4597
+ protocolType: 'V2',
4598
+ },
4599
+ ],
4600
+ }),
4601
+ },
4602
+ updateDescriptor: jest.fn(),
4603
+ updateFromCache: jest.fn(),
4604
+ hasDeviceAcquire: jest.fn(() => true),
4605
+ acquire,
4606
+ commands,
4607
+ getCommands: () => commands,
4608
+ mainId: 'usb-session',
4609
+ });
4610
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4611
+ devices: {},
4612
+ deviceList: [{ getConnectId: () => 'usb-path' }],
4613
+ } as any);
4614
+ (method as any).device = device;
4615
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4616
+
4617
+ try {
4618
+ await (method as any).reconnectProtocolV2Device();
4619
+ await (method as any).reconnectProtocolV2Device();
4620
+ } finally {
4621
+ getDevices.mockRestore();
4622
+ }
4623
+
4624
+ expect(acquire).not.toHaveBeenCalled();
4625
+ expect(device.hasDeviceAcquire).toHaveBeenCalledTimes(2);
4626
+ });
4627
+
4628
+ test('stops final reconnect polling immediately when firmware changes device identity', async () => {
4629
+ const method = new FirmwareUpdateV4({
4630
+ id: 1,
4631
+ payload: {
4632
+ method: 'firmwareUpdateV4',
4633
+ },
4634
+ });
4635
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4636
+ const typedCall = jest.fn().mockResolvedValue({
4637
+ type: 'DeviceInfo',
4638
+ message: {
4639
+ hw: { serial_no: 'PR2SERIAL' },
4640
+ fw: { application: { version: '1.0.0' } },
4641
+ },
4642
+ });
4643
+ (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
4644
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4645
+ (method as any).device = stubDevice({
4646
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4647
+ getCommands: () => ({ typedCall }),
4648
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4649
+ deviceId: 'changed-device',
4650
+ mode: 'normal',
4651
+ bootloaderMode: false,
4652
+ }),
4653
+ });
4654
+
4655
+ await expect((method as any).waitForProtocolV2ReconnectAndFeatures(60_000)).rejects.toThrow(
4656
+ 'identity mismatch'
4657
+ );
4658
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4659
+ });
4660
+
4661
+ test('reboots Protocol V2 firmware flow back to normal without legacy switch-firmware prompt', async () => {
4662
+ const method = new FirmwareUpdateV4({
4663
+ id: 1,
4664
+ payload: {
4665
+ method: 'firmwareUpdateV4',
4666
+ },
4667
+ });
4668
+ method.postTipMessage = jest.fn();
4669
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4670
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4671
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4672
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
4673
+ (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4674
+ message: 'Device rebooted successfully',
4675
+ });
4676
+
4677
+ await (method as any).exitProtocolV2BootloaderToNormal();
4678
+
4679
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4680
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4681
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4682
+ expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4683
+ });
4684
+
4685
+ test('skips a second Normal reboot after Pro2 already returned to App mode', async () => {
4686
+ const method = new FirmwareUpdateV4({
4687
+ id: 1,
4688
+ payload: {
4689
+ method: 'firmwareUpdateV4',
4690
+ },
4691
+ });
4692
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4693
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4694
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4695
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4696
+ (method as any).protocolV2Reboot = jest.fn();
4697
+
4698
+ await (method as any).exitProtocolV2BootloaderToNormal();
4699
+
4700
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4701
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4702
+ });
4703
+
4704
+ test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4705
+ const method = new FirmwareUpdateV4({
4706
+ id: 1,
4707
+ payload: {
4708
+ method: 'firmwareUpdateV4',
4709
+ },
4710
+ });
4711
+ const typedCall = jest
4712
+ .fn()
4713
+ .mockRejectedValue(
4714
+ new Error("The operation couldn't be completed. (MultiplatformBleAdapter.RxError error 6.)")
4715
+ );
4716
+
4717
+ (method as any).device = stubDevice({
4718
+ getCommands: () => ({ typedCall }),
4719
+ });
4720
+
4721
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
4722
+ message: 'Device rebooted successfully',
4723
+ });
4724
+ });
4107
4725
 
4108
4726
  test('treats direct disconnect during Protocol V2 normal reboot as expected', async () => {
4109
4727
  const method = new FirmwareUpdateV4({
@@ -4125,6 +4743,27 @@ describe('Protocol V2 firmware update targets', () => {
4125
4743
  });
4126
4744
  });
4127
4745
 
4746
+ test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
4747
+ const method = new FirmwareUpdateV4({
4748
+ id: 1,
4749
+ payload: {
4750
+ method: 'firmwareUpdateV4',
4751
+ },
4752
+ });
4753
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
4754
+
4755
+ (method as any).device = stubDevice({
4756
+ getCommands: () => ({ typedCall }),
4757
+ });
4758
+
4759
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
4760
+ message: 'Device rebooted successfully',
4761
+ });
4762
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4763
+ DeviceRebootType.Bootloader
4764
+ );
4765
+ });
4766
+
4128
4767
  test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4129
4768
  const method = new FirmwareUpdateV4({
4130
4769
  id: 1,
@@ -4135,9 +4774,9 @@ describe('Protocol V2 firmware update targets', () => {
4135
4774
  const typedCall = jest
4136
4775
  .fn()
4137
4776
  .mockRejectedValue(
4138
- Object.assign(
4139
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
- { name: 'NotFoundError' }
4777
+ new DOMException(
4778
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4779
+ 'NotFoundError'
4141
4780
  )
4142
4781
  );
4143
4782
 
@@ -4215,7 +4854,7 @@ describe('Protocol V2 firmware update targets', () => {
4215
4854
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4216
4855
  ]);
4217
4856
 
4218
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4857
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4219
4858
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4220
4859
  'DeviceFirmwareUpdateStatusGet',
4221
4860
  'DeviceFirmwareUpdateStatusGet',
@@ -4253,6 +4892,46 @@ describe('Protocol V2 firmware update targets', () => {
4253
4892
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4254
4893
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4255
4894
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4895
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4896
+ });
4897
+
4898
+ test('accepts an empty firmware status dump only after confirming normal mode', async () => {
4899
+ const method = new FirmwareUpdateV4({
4900
+ id: 1,
4901
+ payload: {
4902
+ method: 'firmwareUpdateV4',
4903
+ },
4904
+ });
4905
+ const typedCall = jest.fn().mockResolvedValue({
4906
+ type: 'DeviceFirmwareUpdateStatus',
4907
+ message: { records: [] },
4908
+ });
4909
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4910
+ let now = 0;
4911
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
4912
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4913
+ now += 31 * 1000;
4914
+ callback();
4915
+ return 0 as any;
4916
+ }) as typeof setTimeout);
4917
+
4918
+ (method as any).device = stubDevice({
4919
+ getCommands: () => ({ typedCall }),
4920
+ });
4921
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4922
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4923
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4924
+ method.postProgressMessage = jest.fn();
4925
+
4926
+ await expect(
4927
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
4928
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4929
+ ])
4930
+ ).resolves.toBeUndefined();
4931
+
4932
+ expect(typedCall).toHaveBeenCalledTimes(1);
4933
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4934
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4256
4935
  });
4257
4936
 
4258
4937
  test.each([
@@ -4318,54 +4997,65 @@ describe('Protocol V2 firmware update targets', () => {
4318
4997
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4319
4998
  });
4320
4999
 
4321
- test('rejects a finished target whose payload version conflicts with the plan', () => {
5000
+ test('polls target status after update ACK and finishes when all targets complete', async () => {
4322
5001
  const method = new FirmwareUpdateV4({
4323
5002
  id: 1,
4324
5003
  payload: {
4325
5004
  method: 'firmwareUpdateV4',
4326
- platform: 'desktop',
4327
- expectedTargetVersions: {
4328
- app_v1: '2.0.0',
4329
- },
4330
5005
  },
4331
5006
  });
4332
- method.init();
5007
+ const typedCall = jest.fn().mockResolvedValue({
5008
+ type: 'DeviceFirmwareUpdateStatus',
5009
+ message: {
5010
+ records: [
5011
+ { target_id: 3, status: 2 },
5012
+ { target_id: 4, status: 2 },
5013
+ ],
5014
+ },
5015
+ });
5016
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5017
+
5018
+ (method as any).device = stubDevice({
5019
+ getCommands: () => ({ typedCall }),
5020
+ });
5021
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5022
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4333
5023
  method.postProgressMessage = jest.fn();
4334
5024
 
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');
5025
+ await expect(
5026
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5027
+ { target_id: 3, path: 'vol1:bootloader.bin' },
5028
+ { target_id: 4, path: 'vol1:application_p1.bin' },
5029
+ ])
5030
+ ).resolves.toBeUndefined();
5031
+
5032
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5033
+ expect(typedCall).toHaveBeenCalledWith(
5034
+ 'DeviceFirmwareUpdateStatusGet',
5035
+ 'DeviceFirmwareUpdateStatus',
5036
+ expect.anything(),
5037
+ expect.anything()
5038
+ );
5039
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4341
5040
  });
4342
5041
 
4343
- test('fails install polling immediately with the original firmware verification error', async () => {
5042
+ test('fails clearly when a requested target stays absent from the full status dump', async () => {
4344
5043
  const method = new FirmwareUpdateV4({
4345
5044
  id: 1,
4346
5045
  payload: {
4347
5046
  method: 'firmwareUpdateV4',
4348
- platform: 'desktop',
4349
- expectedTargetVersions: {
4350
- app_v1: '2.0.0',
4351
- },
4352
5047
  },
4353
5048
  });
4354
- method.init();
4355
5049
  const typedCall = jest.fn().mockResolvedValue({
4356
5050
  type: 'DeviceFirmwareUpdateStatus',
4357
5051
  message: {
4358
- records: [{ target_id: 4, status: 2, payload_version: 0x010000 }],
5052
+ records: [{ target_id: 4, status: 2 }],
4359
5053
  },
4360
5054
  });
4361
- const dateNowSpy = jest
4362
- .spyOn(Date, 'now')
4363
- .mockReturnValueOnce(0)
4364
- .mockReturnValueOnce(0)
4365
- .mockReturnValue(300_001);
4366
- const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
4367
- callback: () => void
4368
- ) => {
5055
+ let now = 0;
5056
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5057
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5058
+ now += 31 * 1000;
4369
5059
  callback();
4370
5060
  return 0 as any;
4371
5061
  }) as typeof setTimeout);
@@ -4375,45 +5065,52 @@ describe('Protocol V2 firmware update targets', () => {
4375
5065
  });
4376
5066
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4377
5067
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5068
+ method.postProgressMessage = jest.fn();
4378
5069
 
4379
- try {
4380
- await expect(
4381
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4382
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4383
- ])
4384
- ).rejects.toMatchObject({
4385
- errorCode: HardwareErrorCode.FirmwareVerificationFailed,
4386
- message: expect.stringContaining('reached payload version 65536, expected 131072'),
4387
- });
4388
- } finally {
4389
- dateNowSpy.mockRestore();
4390
- setTimeoutSpy.mockRestore();
4391
- }
4392
-
4393
- expect(typedCall).toHaveBeenCalledTimes(1);
5070
+ await expect(
5071
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5072
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
5073
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5074
+ ])
5075
+ ).rejects.toMatchObject({
5076
+ errorCode: HardwareErrorCode.FirmwareError,
5077
+ message: expect.stringContaining('targetIds=3'),
5078
+ });
5079
+ expect(typedCall).toHaveBeenCalledTimes(2);
4394
5080
  });
4395
5081
 
4396
- test('fails install polling immediately with the original status conflict error', async () => {
5082
+ test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
4397
5083
  const method = new FirmwareUpdateV4({
4398
5084
  id: 1,
4399
5085
  payload: {
4400
5086
  method: 'firmwareUpdateV4',
4401
5087
  },
4402
5088
  });
4403
- const typedCall = jest.fn().mockResolvedValue({
4404
- type: 'DeviceFirmwareUpdateStatus',
4405
- message: {
4406
- records: [{ target_id: 4, status: 2, path: 'vol0:/unexpected.bin' }],
4407
- },
4408
- });
4409
- const dateNowSpy = jest
4410
- .spyOn(Date, 'now')
4411
- .mockReturnValueOnce(0)
4412
- .mockReturnValueOnce(0)
4413
- .mockReturnValue(300_001);
4414
- const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
4415
- callback: () => void
4416
- ) => {
5089
+ const typedCall = jest
5090
+ .fn()
5091
+ .mockResolvedValueOnce({
5092
+ type: 'DeviceFirmwareUpdateStatus',
5093
+ message: { records: [{ target_id: 4, status: 2 }] },
5094
+ })
5095
+ .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
5096
+ .mockResolvedValueOnce({
5097
+ type: 'DeviceFirmwareUpdateStatus',
5098
+ message: { records: [{ target_id: 4, status: 2 }] },
5099
+ })
5100
+ .mockResolvedValueOnce({
5101
+ type: 'DeviceFirmwareUpdateStatus',
5102
+ message: {
5103
+ records: [
5104
+ { target_id: 3, status: 2 },
5105
+ { target_id: 4, status: 2 },
5106
+ ],
5107
+ },
5108
+ });
5109
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5110
+ let now = 0;
5111
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5112
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5113
+ now += 31 * 1000;
4417
5114
  callback();
4418
5115
  return 0 as any;
4419
5116
  }) as typeof setTimeout);
@@ -4421,118 +5118,70 @@ describe('Protocol V2 firmware update targets', () => {
4421
5118
  (method as any).device = stubDevice({
4422
5119
  getCommands: () => ({ typedCall }),
4423
5120
  });
4424
- (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5121
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4425
5122
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5123
+ method.postProgressMessage = jest.fn();
4426
5124
 
4427
- try {
4428
- await expect(
4429
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4430
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4431
- ])
4432
- ).rejects.toMatchObject({
4433
- errorCode: HardwareErrorCode.RuntimeError,
4434
- message: 'Protocol V2 install status conflicts with target 4',
4435
- params: {
4436
- firmwareUpdateCode: 'FirmwareInstallStatusConflict',
4437
- },
4438
- });
4439
- } finally {
4440
- dateNowSpy.mockRestore();
4441
- setTimeoutSpy.mockRestore();
4442
- }
4443
-
4444
- expect(typedCall).toHaveBeenCalledTimes(1);
5125
+ await expect(
5126
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5127
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
5128
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5129
+ ])
5130
+ ).resolves.toBeUndefined();
5131
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5132
+ expect(typedCall).toHaveBeenCalledTimes(4);
4445
5133
  });
4446
5134
 
4447
- test('does not fail a completed multi-app install when final app versions are unobservable', () => {
5135
+ test('uses an eight-minute Pro2 install status window', async () => {
4448
5136
  const method = new FirmwareUpdateV4({
4449
5137
  id: 1,
4450
5138
  payload: {
4451
5139
  method: 'firmwareUpdateV4',
4452
- platform: 'desktop',
4453
- targetsToUpdate: ['app_v1', 'app_v2'],
4454
- expectedTargetVersions: {
4455
- app_v1: '1.0.0',
4456
- app_v2: '2.0.0',
4457
- },
4458
5140
  },
4459
5141
  });
4460
- method.init();
4461
- (method as any).protocolV2LatestFinalFeatures = {
4462
- major_version: 3,
4463
- minor_version: 0,
4464
- patch_version: 0,
4465
- };
4466
- (method as any).protocolV2FinalStatusVerified = true;
5142
+ jest
5143
+ .spyOn(Date, 'now')
5144
+ .mockReturnValueOnce(0)
5145
+ .mockReturnValue(8 * 60 * 1000);
4467
5146
 
4468
- expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
5147
+ await expect(
5148
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5149
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5150
+ ])
5151
+ ).rejects.toThrow('within 480s');
4469
5152
  });
4470
5153
 
4471
- test('rejects unobservable final versions after the status endpoint fallback', () => {
5154
+ test('uses a 90-second Pro2 bootloader reconnect window', async () => {
4472
5155
  const method = new FirmwareUpdateV4({
4473
5156
  id: 1,
4474
5157
  payload: {
4475
5158
  method: 'firmwareUpdateV4',
4476
- platform: 'desktop',
4477
- targetsToUpdate: ['app_v1', 'app_v2'],
4478
- expectedTargetVersions: {
4479
- app_v1: '1.0.0',
4480
- app_v2: '2.0.0',
4481
- },
4482
5159
  },
4483
5160
  });
4484
- method.init();
4485
- (method as any).protocolV2LatestFinalFeatures = {
4486
- major_version: 3,
4487
- minor_version: 0,
4488
- patch_version: 0,
4489
- };
5161
+ jest
5162
+ .spyOn(Date, 'now')
5163
+ .mockReturnValueOnce(0)
5164
+ .mockReturnValue(90 * 1000);
4490
5165
 
4491
- expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
4492
- 'has no observable final version after status fallback'
4493
- );
5166
+ await expect((method as any).waitForProtocolV2BootloaderMode()).rejects.toThrow('within 90s');
4494
5167
  });
4495
5168
 
4496
- test('polls target status after update ACK and finishes when all targets complete', async () => {
5169
+ test('uses a three-minute Pro2 final reconnect window', async () => {
4497
5170
  const method = new FirmwareUpdateV4({
4498
5171
  id: 1,
4499
5172
  payload: {
4500
5173
  method: 'firmwareUpdateV4',
4501
5174
  },
4502
5175
  });
4503
- const typedCall = jest.fn().mockResolvedValue({
4504
- type: 'DeviceFirmwareUpdateStatus',
4505
- message: {
4506
- records: [
4507
- { target_id: 3, status: 2 },
4508
- { target_id: 4, status: 2 },
4509
- ],
4510
- },
4511
- });
4512
- const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4513
-
4514
- (method as any).device = stubDevice({
4515
- getCommands: () => ({ typedCall }),
4516
- });
4517
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4518
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4519
- method.postProgressMessage = jest.fn();
4520
-
4521
- await expect(
4522
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4523
- { target_id: 3, path: 'vol1:bootloader.bin' },
4524
- { target_id: 4, path: 'vol1:application_p1.bin' },
4525
- ])
4526
- ).resolves.toBeUndefined();
5176
+ const waitForProtocolV2ReconnectAndFeatures = jest
5177
+ .fn()
5178
+ .mockRejectedValue(new Error('stop after capturing timeout'));
5179
+ (method as any).waitForProtocolV2ReconnectAndFeatures = waitForProtocolV2ReconnectAndFeatures;
4527
5180
 
4528
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4529
- expect(typedCall).toHaveBeenCalledWith(
4530
- 'DeviceFirmwareUpdateStatusGet',
4531
- 'DeviceFirmwareUpdateStatus',
4532
- expect.anything(),
4533
- expect.anything()
5181
+ await expect((method as any).waitForProtocolV2FinalFeatures()).rejects.toThrow(
5182
+ 'stop after capturing timeout'
4534
5183
  );
4535
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5184
+ expect(waitForProtocolV2ReconnectAndFeatures).toHaveBeenCalledWith(3 * 60 * 1000);
4536
5185
  });
4537
5186
 
4538
5187
  test('uses SDK decoded enum names for Protocol V2 install polling', () => {
@@ -4569,7 +5218,7 @@ describe('Protocol V2 firmware update targets', () => {
4569
5218
  ).toBe(true);
4570
5219
 
4571
5220
  (method.postProgressMessage as jest.Mock).mockClear();
4572
- expect(() =>
5221
+ expect(
4573
5222
  (method as any).assertProtocolV2TargetStatus(
4574
5223
  [
4575
5224
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4577,8 +5226,8 @@ describe('Protocol V2 firmware update targets', () => {
4577
5226
  ],
4578
5227
  new Set([4, 10])
4579
5228
  )
4580
- ).toThrow('Protocol V2 install status conflicts with target 4');
4581
- expect(method.postProgressMessage).not.toHaveBeenCalled();
5229
+ ).toBe(false);
5230
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4582
5231
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4583
5232
 
4584
5233
  expect(
@@ -4667,7 +5316,7 @@ describe('Protocol V2 firmware update targets', () => {
4667
5316
  path: true,
4668
5317
  },
4669
5318
  },
4670
- { timeoutMs: 5000 }
5319
+ { timeoutMs: 15000 }
4671
5320
  );
4672
5321
  });
4673
5322
 
@@ -4682,14 +5331,10 @@ describe('Protocol V2 firmware update targets', () => {
4682
5331
  const writtenPaths: string[] = [];
4683
5332
  method.postTipMessage = jest.fn();
4684
5333
  method.postProgressMessage = jest.fn();
4685
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
5334
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4686
5335
  writtenPaths.push(params.filePath);
4687
- return Number(params.processedSize ?? 0) + Number(params.source.size);
5336
+ return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4688
5337
  });
4689
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4690
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4691
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
4692
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4693
5338
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4694
5339
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4695
5340
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4729,12 +5374,10 @@ describe('Protocol V2 firmware update targets', () => {
4729
5374
  'vol0:/coprocessor.bin',
4730
5375
  1
4731
5376
  );
4732
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
4733
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
4734
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
4735
- });
4736
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
5377
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
5378
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4737
5379
  targets: [
5380
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
4738
5381
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4739
5382
  { target_id: 7, path: 'vol0:/se01.bin' },
4740
5383
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4744,7 +5387,7 @@ describe('Protocol V2 firmware update targets', () => {
4744
5387
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4745
5388
  });
4746
5389
 
4747
- test('announces separate resource and component transfer phases', async () => {
5390
+ test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4748
5391
  const method = new FirmwareUpdateV4({
4749
5392
  id: 1,
4750
5393
  payload: {
@@ -4754,14 +5397,11 @@ describe('Protocol V2 firmware update targets', () => {
4754
5397
 
4755
5398
  method.postTipMessage = jest.fn();
4756
5399
  method.postProgressMessage = jest.fn();
4757
- (method as any).protocolV2SourceUpdateProcess = jest
5400
+ (method as any).protocolV2CommonUpdateProcess = jest
4758
5401
  .fn()
4759
5402
  .mockImplementation(params =>
4760
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5403
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4761
5404
  );
4762
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4763
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4764
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4765
5405
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4766
5406
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4767
5407
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4781,22 +5421,21 @@ describe('Protocol V2 firmware update targets', () => {
4781
5421
  {
4782
5422
  fileName: 'application_p1.bin',
4783
5423
  binary: new Uint8Array([3]).buffer,
4784
- targetId: 4,
5424
+ targetId: 3,
4785
5425
  },
4786
5426
  ],
4787
5427
  });
4788
5428
 
4789
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
5429
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4790
5430
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
4791
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4792
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4793
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5431
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5432
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4794
5433
  1,
4795
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
5434
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
4796
5435
  );
4797
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5436
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4798
5437
  2,
4799
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
5438
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
4800
5439
  );
4801
5440
  });
4802
5441
 
@@ -4810,8 +5449,7 @@ describe('Protocol V2 firmware update targets', () => {
4810
5449
 
4811
5450
  method.postTipMessage = jest.fn();
4812
5451
  method.postProgressMessage = jest.fn();
4813
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4814
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5452
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4815
5453
  (method as any).device = stubDevice({
4816
5454
  getCommands: () => ({
4817
5455
  typedCall: jest.fn().mockResolvedValue({
@@ -4857,14 +5495,11 @@ describe('Protocol V2 firmware update targets', () => {
4857
5495
 
4858
5496
  method.postTipMessage = jest.fn();
4859
5497
  method.postProgressMessage = jest.fn();
4860
- (method as any).protocolV2SourceUpdateProcess = jest
5498
+ (method as any).protocolV2CommonUpdateProcess = jest
4861
5499
  .fn()
4862
5500
  .mockImplementation(params =>
4863
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5501
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4864
5502
  );
4865
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4866
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4867
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4868
5503
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4869
5504
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4870
5505
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5079,328 +5714,6 @@ describe('Protocol V2 firmware update targets', () => {
5079
5714
  getFirmwareLatestReleaseSpy.mockRestore();
5080
5715
  });
5081
5716
 
5082
- test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5083
- const method = new FirmwareUpdateV4({
5084
- id: 1,
5085
- payload: {
5086
- method: 'firmwareUpdateV4',
5087
- platform: 'web',
5088
- targetsToUpdate: ['resource'],
5089
- },
5090
- });
5091
- method.init();
5092
-
5093
- const getFirmwareLatestReleaseSpy = jest
5094
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5095
- .mockReturnValue({
5096
- required: false,
5097
- version: [1, 0, 0],
5098
- url: '',
5099
- resourceBundles: [
5100
- {
5101
- name: 'images',
5102
- url: 'https://example.com/images.okpkg',
5103
- devicePath: 'VOL0:/resource/images/images.okpkg',
5104
- version: [2, 0, 0],
5105
- payloadHash: 'ab'.repeat(64),
5106
- headerHash: 'cd'.repeat(64),
5107
- },
5108
- ],
5109
- fingerprint: '',
5110
- changelog: {
5111
- 'zh-CN': '',
5112
- 'en-US': '',
5113
- },
5114
- });
5115
-
5116
- const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5117
- deviceType: 'pro2',
5118
- firmwareVersion: '1.0.0',
5119
- capabilities: [],
5120
- });
5121
-
5122
- expect(bundles).toEqual([
5123
- {
5124
- name: 'images',
5125
- binary: expect.any(ArrayBuffer),
5126
- devicePath: 'vol0:/resource/images/images.okpkg',
5127
- url: 'https://example.com/images.okpkg',
5128
- version: [2, 0, 0],
5129
- payloadHash: 'ab'.repeat(64),
5130
- headerHash: 'cd'.repeat(64),
5131
- },
5132
- ]);
5133
- expect(bundles[0].binary.byteLength).toBe(0);
5134
-
5135
- getFirmwareLatestReleaseSpy.mockRestore();
5136
- });
5137
-
5138
- test('rejects remote RESC bundle paths before download or bootloader entry', 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 getSysResourceBinarySpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
5151
- const getFirmwareLatestReleaseSpy = jest
5152
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5153
- .mockReturnValue({
5154
- required: false,
5155
- version: [1, 0, 0],
5156
- url: '',
5157
- resourceBundles: [
5158
- {
5159
- name: 'images',
5160
- url: 'https://example.com/images.okpkg',
5161
- devicePath: 'vol0:/resource/../images.okpkg',
5162
- },
5163
- ],
5164
- fingerprint: '',
5165
- changelog: {
5166
- 'zh-CN': '',
5167
- 'en-US': '',
5168
- },
5169
- });
5170
-
5171
- (method as any).device = stubDevice({
5172
- originalDescriptor: { protocolType: 'V2' },
5173
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5174
- });
5175
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5176
- bootloaderBinary: null,
5177
- fwBinaryMap: [],
5178
- });
5179
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5180
- method.postTipMessage = jest.fn();
5181
-
5182
- await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5183
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5184
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5185
-
5186
- getSysResourceBinarySpy.mockRestore();
5187
- getFirmwareLatestReleaseSpy.mockRestore();
5188
- });
5189
-
5190
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5191
- const method = new FirmwareUpdateV4({
5192
- id: 1,
5193
- payload: {
5194
- method: 'firmwareUpdateV4',
5195
- },
5196
- });
5197
- const openPreparedSource = jest.fn();
5198
- (method as any).params = {
5199
- targetsToUpdate: ['resource'],
5200
- resourceBundleArtifacts: [
5201
- {
5202
- name: 'images',
5203
- artifact: {
5204
- artifactRef: `fw:${'a'.repeat(64)}`,
5205
- size: 1,
5206
- sha256: 'a'.repeat(64),
5207
- },
5208
- },
5209
- ],
5210
- };
5211
- (method as any).openProtocolV2PreparedSource = openPreparedSource;
5212
- const getFirmwareLatestReleaseSpy = jest
5213
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5214
- .mockReturnValue({
5215
- required: false,
5216
- version: [1, 0, 0],
5217
- url: '',
5218
- resourceBundles: [
5219
- {
5220
- name: 'images',
5221
- url: 'https://example.com/images.okpkg',
5222
- devicePath: 'vol0:/resource/../images.okpkg',
5223
- },
5224
- ],
5225
- fingerprint: '',
5226
- changelog: {
5227
- 'zh-CN': '',
5228
- 'en-US': '',
5229
- },
5230
- });
5231
-
5232
- await expect(
5233
- (method as any).prepareProtocolV2ResourceSources('universal', {
5234
- deviceType: 'pro2',
5235
- })
5236
- ).rejects.toThrow('resourceBundles[].devicePath');
5237
- expect(openPreparedSource).not.toHaveBeenCalled();
5238
-
5239
- getFirmwareLatestReleaseSpy.mockRestore();
5240
- });
5241
-
5242
- test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5243
- const method = new FirmwareUpdateV4({
5244
- id: 1,
5245
- payload: {
5246
- method: 'firmwareUpdateV4',
5247
- platform: 'web',
5248
- targetsToUpdate: ['resource'],
5249
- },
5250
- });
5251
- method.init();
5252
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5253
-
5254
- const order: string[] = [];
5255
- const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5256
- const getSysResourceBinarySpy = jest
5257
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5258
- .mockImplementation(() => {
5259
- order.push('download');
5260
- return Promise.resolve({ binary: resourceBinary });
5261
- });
5262
- const getFirmwareLatestReleaseSpy = jest
5263
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5264
- .mockReturnValue({
5265
- required: false,
5266
- version: [1, 0, 0],
5267
- url: '',
5268
- resourceBundles: [
5269
- {
5270
- name: 'images',
5271
- url: 'https://example.com/images.okpkg',
5272
- devicePath: ' VOL0:/resource/images/images.okpkg ',
5273
- },
5274
- ],
5275
- fingerprint: '',
5276
- changelog: {
5277
- 'zh-CN': '',
5278
- 'en-US': '',
5279
- },
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().mockImplementation(() => {
5291
- order.push('bootloader');
5292
- return Promise.resolve(true);
5293
- });
5294
- const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5295
- (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5296
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5297
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5298
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5299
- bootloaderVersion: '1.0.0',
5300
- bleVersion: '0.0.0',
5301
- firmwareVersion: '1.0.0',
5302
- });
5303
- method.postTipMessage = jest.fn();
5304
-
5305
- await method.run();
5306
-
5307
- expect(order).toEqual(['download', 'bootloader']);
5308
- expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5309
- bootloaderBinary: null,
5310
- fwBinaryMap: [],
5311
- resourceBundles: [
5312
- {
5313
- name: 'images',
5314
- binary: resourceBinary,
5315
- devicePath: 'vol0:/resource/images/images.okpkg',
5316
- url: 'https://example.com/images.okpkg',
5317
- },
5318
- ],
5319
- });
5320
-
5321
- executeProtocolV2UpdateSpy.mockRestore();
5322
- getSysResourceBinarySpy.mockRestore();
5323
- getFirmwareLatestReleaseSpy.mockRestore();
5324
- });
5325
-
5326
- test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5327
- const method = new FirmwareUpdateV4({
5328
- id: 1,
5329
- payload: {
5330
- method: 'firmwareUpdateV4',
5331
- },
5332
- });
5333
- method.init();
5334
- const getSysResourceBinarySpy = jest
5335
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5336
- .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5337
-
5338
- await expect(
5339
- (method as any).materializeProtocolV2ResourceBundles([
5340
- {
5341
- name: 'images',
5342
- binary: new ArrayBuffer(0),
5343
- devicePath: 'vol0:/resource/images/images.okpkg',
5344
- url: 'https://example.com/images.okpkg',
5345
- },
5346
- ])
5347
- ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5348
-
5349
- getSysResourceBinarySpy.mockRestore();
5350
- });
5351
-
5352
- test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5353
- const method = new FirmwareUpdateV4({
5354
- id: 1,
5355
- payload: {
5356
- method: 'firmwareUpdateV4',
5357
- platform: 'web',
5358
- targetsToUpdate: ['resource'],
5359
- },
5360
- });
5361
- method.init();
5362
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5363
-
5364
- const getSysResourceBinarySpy = jest
5365
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5366
- .mockResolvedValue({ binary: new ArrayBuffer(0) });
5367
- const getFirmwareLatestReleaseSpy = jest
5368
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5369
- .mockReturnValue({
5370
- required: false,
5371
- version: [1, 0, 0],
5372
- url: '',
5373
- resourceBundles: [
5374
- {
5375
- name: 'images',
5376
- url: 'https://example.com/images.okpkg',
5377
- devicePath: 'vol0:/resource/images/images.okpkg',
5378
- },
5379
- ],
5380
- fingerprint: '',
5381
- changelog: {
5382
- 'zh-CN': '',
5383
- 'en-US': '',
5384
- },
5385
- });
5386
- (method as any).device = stubDevice({
5387
- originalDescriptor: { protocolType: 'V2' },
5388
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5389
- });
5390
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5391
- bootloaderBinary: null,
5392
- fwBinaryMap: [],
5393
- });
5394
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5395
- method.postTipMessage = jest.fn();
5396
-
5397
- await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5398
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5399
-
5400
- getSysResourceBinarySpy.mockRestore();
5401
- getFirmwareLatestReleaseSpy.mockRestore();
5402
- });
5403
-
5404
5717
  test('does not download Pro2 firmware components that App did not select', async () => {
5405
5718
  const method = new FirmwareUpdateV4({
5406
5719
  id: 1,
@@ -5488,24 +5801,55 @@ describe('Protocol V2 firmware update targets', () => {
5488
5801
  originalDescriptor: { protocolType: 'V2' },
5489
5802
  features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5490
5803
  });
5491
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5492
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5493
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5494
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5495
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5496
- bootloaderVersion: '1.0.0',
5497
- bleVersion: '0.0.0',
5498
- firmwareVersion: '1.0.0',
5499
- });
5804
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5805
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5806
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5807
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5808
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5809
+ bootloaderVersion: '1.0.0',
5810
+ bleVersion: '0.0.0',
5811
+ firmwareVersion: '1.0.0',
5812
+ });
5813
+ method.postTipMessage = jest.fn();
5814
+
5815
+ await method.run();
5816
+
5817
+ expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5818
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5819
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5820
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5821
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5822
+ });
5823
+ });
5824
+
5825
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5826
+ const method = new FirmwareUpdateV4({
5827
+ id: 1,
5828
+ payload: {
5829
+ method: 'firmwareUpdateV4',
5830
+ platform: 'web',
5831
+ },
5832
+ });
5833
+ method.init();
5834
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5835
+ (method as any).device = stubDevice({
5836
+ originalDescriptor: { protocolType: 'V2' },
5837
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5838
+ });
5839
+ forceReloadDataSpy.mockRejectedValue(
5840
+ ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
5841
+ );
5842
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5843
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5500
5844
  method.postTipMessage = jest.fn();
5501
5845
 
5502
- await method.run();
5503
-
5504
- expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5505
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5506
- bootloaderBinary: remoteBinaries.bootloaderBinary,
5507
- fwBinaryMap: remoteBinaries.fwBinaryMap,
5846
+ await expect(method.run()).rejects.toMatchObject({
5847
+ errorCode: HardwareErrorCode.NetworkError,
5848
+ message: 'latest config unavailable',
5508
5849
  });
5850
+
5851
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5852
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5509
5853
  });
5510
5854
 
5511
5855
  test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
@@ -5544,14 +5888,14 @@ describe('Protocol V2 firmware update targets', () => {
5544
5888
  });
5545
5889
  });
5546
5890
 
5547
- test('treats manual resource bundles as explicit payload without remote firmware auto-fill', async () => {
5891
+ test('treats manual resource files as explicit payload without remote firmware auto-fill', async () => {
5548
5892
  const resourceBundle = new Uint8Array([1, 2, 3]).buffer;
5549
5893
  const method = new FirmwareUpdateV4({
5550
5894
  id: 1,
5551
5895
  payload: {
5552
5896
  method: 'firmwareUpdateV4',
5553
5897
  platform: 'web',
5554
- resourceBundleFiles: [
5898
+ resourceFiles: [
5555
5899
  {
5556
5900
  binary: resourceBundle,
5557
5901
  devicePath: ' VOL0:/resource/images/images.okpkg ',
@@ -5599,7 +5943,7 @@ describe('Protocol V2 firmware update targets', () => {
5599
5943
  payload: {
5600
5944
  method: 'firmwareUpdateV4',
5601
5945
  platform: 'web',
5602
- resourceBundleFiles: [
5946
+ resourceFiles: [
5603
5947
  {
5604
5948
  binary: new Uint8Array([1, 2, 3]).buffer,
5605
5949
  devicePath: 'vol2:/resource/images/images.okpkg',
@@ -5618,7 +5962,7 @@ describe('Protocol V2 firmware update targets', () => {
5618
5962
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
5619
5963
  method.postTipMessage = jest.fn();
5620
5964
 
5621
- await expect(method.run()).rejects.toThrow('resourceBundleFiles[0].devicePath');
5965
+ await expect(method.run()).rejects.toThrow('resourceFiles[0].devicePath');
5622
5966
  expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5623
5967
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5624
5968
  });
@@ -5633,10 +5977,7 @@ describe('Protocol V2 firmware update targets', () => {
5633
5977
 
5634
5978
  method.postTipMessage = jest.fn();
5635
5979
  method.postProgressMessage = jest.fn();
5636
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5637
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5638
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5639
- (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5980
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5640
5981
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5641
5982
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5642
5983
 
@@ -5652,7 +5993,7 @@ describe('Protocol V2 firmware update targets', () => {
5652
5993
  fwBinaryMap: [],
5653
5994
  });
5654
5995
 
5655
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5996
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5656
5997
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5657
5998
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5658
5999
  });
@@ -5680,22 +6021,17 @@ describe('Protocol V2 firmware update targets', () => {
5680
6021
 
5681
6022
  (method as any).device = stubDevice({
5682
6023
  getCommands: () => ({ typedCall }),
6024
+ getCurrentDeviceType: () => 'pro2',
6025
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
5683
6026
  });
5684
- method.postProgressMessage = jest.fn();
6027
+ method.postMessage = jest.fn();
5685
6028
 
5686
- const source = await openFirmwareByteSource({
5687
- binary: new Uint8Array(4097).buffer,
6029
+ await (method as any).protocolV2CommonUpdateProcess({
6030
+ payload: new Uint8Array(4097).buffer,
6031
+ filePath: 'vol1:firmware.bin',
6032
+ processedSize: 0,
6033
+ totalSize: 4097,
5688
6034
  });
5689
- try {
5690
- await (method as any).protocolV2SourceUpdateProcess({
5691
- source,
5692
- filePath: 'vol1:firmware.bin',
5693
- processedSize: 0,
5694
- totalSize: 4097,
5695
- });
5696
- } finally {
5697
- await source?.close();
5698
- }
5699
6035
 
5700
6036
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5701
6037
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5703,6 +6039,17 @@ describe('Protocol V2 firmware update targets', () => {
5703
6039
  expect(writePayloads.map(payload => payload.overwrite)).toEqual([true, false]);
5704
6040
  expect(writePayloads.every(payload => payload.append === false)).toBe(true);
5705
6041
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
6042
+ expect(method.postMessage).toHaveBeenLastCalledWith({
6043
+ event: 'UI_EVENT',
6044
+ type: UI_REQUEST.FIRMWARE_PROGRESS,
6045
+ payload: expect.objectContaining({
6046
+ progress: 99,
6047
+ progressType: 'transferData',
6048
+ transferredBytes: 4097,
6049
+ totalBytes: 4097,
6050
+ elapsedMs: expect.any(Number),
6051
+ }),
6052
+ });
5706
6053
  });
5707
6054
 
5708
6055
  test('restarts the whole file from offset zero after an ambiguous chunk write failure', async () => {
@@ -5752,16 +6099,12 @@ describe('Protocol V2 firmware update targets', () => {
5752
6099
  method.postProgressMessage = jest.fn();
5753
6100
 
5754
6101
  try {
5755
- const source = await openFirmwareByteSource({
5756
- binary: new Uint8Array(8001).buffer,
5757
- });
5758
- await (method as any).protocolV2SourceUpdateProcess({
5759
- source,
6102
+ await (method as any).protocolV2CommonUpdateProcess({
6103
+ payload: new Uint8Array(8001).buffer,
5760
6104
  filePath: 'vol0:/firmware.bin',
5761
6105
  processedSize: 0,
5762
6106
  totalSize: 8001,
5763
6107
  });
5764
- await source?.close();
5765
6108
  } finally {
5766
6109
  setTimeoutSpy.mockRestore();
5767
6110
  }
@@ -5796,21 +6139,16 @@ describe('Protocol V2 firmware update targets', () => {
5796
6139
  });
5797
6140
  method.postProgressMessage = jest.fn();
5798
6141
  method.postTipMessage = jest.fn();
5799
- (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5800
6142
 
5801
- const source = await openFirmwareByteSource({
5802
- binary: new Uint8Array(4097).buffer,
5803
- });
5804
6143
  await expect(
5805
- (method as any).protocolV2SourceUpdateProcess({
5806
- source,
6144
+ (method as any).protocolV2WriteWholeFile({
6145
+ payload: new Uint8Array(4097).buffer,
5807
6146
  filePath: 'vol0:/firmware.bin',
5808
6147
  processedSize: 0,
5809
6148
  totalSize: 4097,
5810
6149
  })
5811
6150
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5812
- await source?.close();
5813
- expect(typedCall).toHaveBeenCalledTimes(6);
6151
+ expect(typedCall).toHaveBeenCalledTimes(2);
5814
6152
  });
5815
6153
 
5816
6154
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5843,24 +6181,27 @@ describe('Protocol V2 firmware update targets', () => {
5843
6181
  });
5844
6182
  method.postProgressMessage = jest.fn();
5845
6183
 
5846
- const source = await openFirmwareByteSource({
5847
- binary: new Uint8Array(1801).buffer,
6184
+ await (method as any).protocolV2CommonUpdateProcess({
6185
+ payload: new Uint8Array(1801).buffer,
6186
+ filePath: 'vol1:ble-firmware.bin',
6187
+ processedSize: 0,
6188
+ totalSize: 1801,
5848
6189
  });
5849
- try {
5850
- await (method as any).protocolV2SourceUpdateProcess({
5851
- source,
5852
- filePath: 'vol1:ble-firmware.bin',
5853
- processedSize: 0,
5854
- totalSize: 1801,
5855
- });
5856
- } finally {
5857
- await source?.close();
5858
- }
5859
6190
 
5860
6191
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5861
6192
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
5862
6193
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
5863
6194
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
6195
+ expect(typedCall.mock.calls.map(call => call[3])).toEqual([
6196
+ expect.objectContaining({
6197
+ writeWithResponse: false,
6198
+ onWriteCompleted: expect.any(Function),
6199
+ }),
6200
+ expect.objectContaining({
6201
+ writeWithResponse: false,
6202
+ onWriteCompleted: expect.any(Function),
6203
+ }),
6204
+ ]);
5864
6205
  });
5865
6206
 
5866
6207
  test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
@@ -5972,9 +6313,9 @@ describe('Protocol V2 firmware update method', () => {
5972
6313
  const typedCall = jest
5973
6314
  .fn()
5974
6315
  .mockRejectedValue(
5975
- Object.assign(
5976
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5977
- { name: 'NotFoundError' }
6316
+ new DOMException(
6317
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
6318
+ 'NotFoundError'
5978
6319
  )
5979
6320
  );
5980
6321
 
@@ -6028,7 +6369,7 @@ describe('Protocol V2 firmware update method', () => {
6028
6369
  expect(typedCall).not.toHaveBeenCalled();
6029
6370
  });
6030
6371
 
6031
- test.each([0, 2])(
6372
+ test.each([0, 1, 2])(
6032
6373
  'rejects firmware target %s because the Pro2 bootloader cannot install it',
6033
6374
  async targetId => {
6034
6375
  const typedCall = jest.fn();
@@ -6061,8 +6402,8 @@ describe('Protocol V2 firmware update method', () => {
6061
6402
  targets: [
6062
6403
  {
6063
6404
  target_id: undefined,
6064
- targetId: 1,
6065
- path: 'vol0:resource.crate.okpkg',
6405
+ targetId: 3,
6406
+ path: 'vol0:bootloader.okpkg',
6066
6407
  },
6067
6408
  ],
6068
6409
  } as any,
@@ -6074,7 +6415,7 @@ describe('Protocol V2 firmware update method', () => {
6074
6415
 
6075
6416
  await method.run();
6076
6417
  expect(typedCall.mock.calls[0][2]).toEqual({
6077
- targets: [{ target_id: 1, path: 'vol0:resource.crate.okpkg' }],
6418
+ targets: [{ target_id: 3, path: 'vol0:bootloader.okpkg' }],
6078
6419
  });
6079
6420
  });
6080
6421
 
@@ -6139,20 +6480,63 @@ describe('Protocol V2 firmware update method', () => {
6139
6480
  });
6140
6481
 
6141
6482
  describe('Protocol V2 firmware reconnect identity', () => {
6142
- test('keeps the host plan identity as the V4 reconnect anchor', () => {
6143
- const method = new FirmwareUpdateV4({
6144
- id: 1,
6145
- payload: {
6146
- method: 'firmwareUpdateV4',
6147
- platform: 'native',
6148
- expectedDeviceId: 'persisted-device-id',
6149
- },
6483
+ const createResourceFilesystemTypedCall = (
6484
+ stable: Array<{ type: string; size: number; headerHash: string }>,
6485
+ missingTypes: string[] = []
6486
+ ) => {
6487
+ const missing = new Set(missingTypes);
6488
+ const resourceByPath = new Map(
6489
+ stable.map(resource => [
6490
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6491
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6492
+ ],
6493
+ resource,
6494
+ ])
6495
+ );
6496
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6497
+ if (requestType === 'ResourceInventoryGet') {
6498
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
6499
+ }
6500
+ if (requestType === 'FilesystemPathInfoQuery') {
6501
+ const resource = resourceByPath.get(payload.path);
6502
+ const exists = Boolean(resource && !missing.has(resource.type));
6503
+ return {
6504
+ message: {
6505
+ exist: exists,
6506
+ directory: false,
6507
+ size: exists ? resource?.size : 0,
6508
+ },
6509
+ };
6510
+ }
6511
+ if (requestType === 'FilesystemFileRead') {
6512
+ const resource = resourceByPath.get(payload.file.path);
6513
+ if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6514
+ const header = new Uint8Array(0x52a0);
6515
+ const view = new DataView(header.buffer);
6516
+ 'OKPP'.split('').forEach((char, index) => {
6517
+ header[index] = char.charCodeAt(0);
6518
+ });
6519
+ 'RESC'.split('').forEach((char, index) => {
6520
+ header[0x08 + index] = char.charCodeAt(0);
6521
+ });
6522
+ view.setUint32(0x0c, header.byteLength, true);
6523
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
6524
+ header[0x240 + index] = Number.parseInt(
6525
+ resource.headerHash.slice(index * 2, index * 2 + 2),
6526
+ 16
6527
+ );
6528
+ }
6529
+ const offset = Number(payload.file.offset);
6530
+ const chunkLength = Number(payload.chunk_len);
6531
+ return {
6532
+ message: {
6533
+ data: header.slice(offset, offset + chunkLength),
6534
+ },
6535
+ };
6536
+ }
6537
+ throw new Error(`Unexpected request: ${requestType}`);
6150
6538
  });
6151
-
6152
- method.init();
6153
-
6154
- expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6155
- });
6539
+ };
6156
6540
 
6157
6541
  test('rejects a different physical serial before firmware transfer resumes', () => {
6158
6542
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
@@ -6163,15 +6547,15 @@ describe('Protocol V2 firmware reconnect identity', () => {
6163
6547
  ).not.toThrow();
6164
6548
  });
6165
6549
 
6166
- test('fails closed when the physical serial is unavailable', () => {
6167
- expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
6168
- expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6169
- );
6170
- expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).toThrow(
6171
- expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6172
- );
6550
+ test('requires the same descriptor path when either physical serial is unavailable', () => {
6551
+ expect(() =>
6552
+ assertProtocolV2ReconnectIdentity('expected-serial', undefined, 'usb-path', 'usb-path')
6553
+ ).not.toThrow();
6554
+ expect(() =>
6555
+ assertProtocolV2ReconnectIdentity(undefined, 'actual-serial', 'usb-path', 'other-path')
6556
+ ).toThrow('identity unavailable');
6173
6557
  expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).toThrow(
6174
- expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6558
+ 'identity unavailable'
6175
6559
  );
6176
6560
  });
6177
6561
 
@@ -6190,6 +6574,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6190
6574
  },
6191
6575
  });
6192
6576
  (method as any).device = stubDevice({
6577
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
6193
6578
  features: { deviceId: 'wallet-derived-id' },
6194
6579
  getCommands: () => ({ typedCall }),
6195
6580
  });
@@ -6205,7 +6590,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6205
6590
  );
6206
6591
  });
6207
6592
 
6208
- test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
6593
+ test('allows firmware update startup when DeviceInfo has no physical serial', async () => {
6209
6594
  const method = new FirmwareUpdateV4({
6210
6595
  id: 1,
6211
6596
  payload: { method: 'firmwareUpdateV4' },
@@ -6215,42 +6600,204 @@ describe('Protocol V2 firmware reconnect identity', () => {
6215
6600
  message: { protocol_version: 1, hw: {} },
6216
6601
  });
6217
6602
  (method as any).device = stubDevice({
6603
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
6218
6604
  getCommands: () => ({ typedCall }),
6219
6605
  });
6220
6606
 
6221
- await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
6222
- errorCode: HardwareErrorCode.DeviceNotFound,
6223
- });
6607
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
6608
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
6609
+ expect((method as any).protocolV2ExpectedPath).toBeDefined();
6224
6610
  });
6225
6611
 
6226
- test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
6612
+ test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6227
6613
  const method = new FirmwareUpdateV4({
6228
6614
  id: 1,
6229
6615
  payload: {
6230
6616
  method: 'firmwareUpdateV4',
6231
- platform: 'native',
6232
- chunkSize: 1800,
6617
+ platform: 'web',
6618
+ targetsToUpdate: ['resource'],
6233
6619
  },
6234
6620
  });
6235
6621
  method.init();
6236
- const readChunkLengths: number[] = [];
6237
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6238
- if (requestType === 'FilesystemPathInfoQuery') {
6239
- return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
6240
- }
6241
- if (requestType === 'FilesystemFileRead') {
6242
- readChunkLengths.push(request.chunk_len);
6243
- return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
6244
- }
6245
- throw new Error(`Unexpected request: ${requestType}`);
6622
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6623
+ (type, index) => ({
6624
+ type,
6625
+ url: `https://example.com/${type}.okpkg`,
6626
+ size: 0x52a0 + index + 1,
6627
+ fileHash: 'a'.repeat(64),
6628
+ headerHash: index.toString(16).padStart(128, '0'),
6629
+ })
6630
+ );
6631
+ const typedCall = createResourceFilesystemTypedCall(stable);
6632
+ (method as any).device = stubDevice({
6633
+ getCommands: () => ({ typedCall }),
6634
+ getCurrentDeviceType: () => 'pro2',
6635
+ });
6636
+ const resourcesSpy = jest
6637
+ .spyOn(DataManager, 'getProtocolV2Resources')
6638
+ .mockReturnValue(stable as any);
6639
+ (method as any).downloadProtocolV2Resource = jest.fn();
6640
+
6641
+ await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6642
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6643
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6644
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6645
+ resourcesSpy.mockRestore();
6646
+ });
6647
+
6648
+ test('downloads only changed resources from loader inventory', async () => {
6649
+ const method = new FirmwareUpdateV4({
6650
+ id: 1,
6651
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6652
+ });
6653
+ method.init();
6654
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6655
+ (type, index) => ({
6656
+ type,
6657
+ url: `https://example.com/${type}.okpkg`,
6658
+ size: 0x52a0 + index + 1,
6659
+ fileHash: 'a'.repeat(64),
6660
+ headerHash: index.toString(16).padStart(128, '0'),
6661
+ })
6662
+ );
6663
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6664
+ (method as any).device = stubDevice({
6665
+ getCommands: () => ({ typedCall }),
6666
+ getCurrentDeviceType: () => 'pro2',
6667
+ });
6668
+ const resourcesSpy = jest
6669
+ .spyOn(DataManager, 'getProtocolV2Resources')
6670
+ .mockReturnValue(stable as any);
6671
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6672
+ Promise.resolve({
6673
+ name: `${resource.type}.okpkg`,
6674
+ binary: new ArrayBuffer(resource.size),
6675
+ devicePath: `vol0:/${resource.type}.okpkg`,
6676
+ })
6677
+ );
6678
+
6679
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6680
+
6681
+ expect(bundles).toHaveLength(1);
6682
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6683
+ resourcesSpy.mockRestore();
6684
+ });
6685
+
6686
+ test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6687
+ const method = new FirmwareUpdateV4({
6688
+ id: 1,
6689
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6246
6690
  });
6691
+ method.init();
6692
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6693
+ (type, index) => ({
6694
+ type,
6695
+ url: `https://example.com/${type}.okpkg`,
6696
+ size: 0x52a0 + index + 1,
6697
+ fileHash: 'a'.repeat(64),
6698
+ headerHash: index.toString(16).padStart(128, '0'),
6699
+ })
6700
+ );
6701
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6247
6702
  (method as any).device = stubDevice({
6248
6703
  getCommands: () => ({ typedCall }),
6704
+ getCurrentDeviceType: () => 'pro2',
6705
+ });
6706
+ const resourcesSpy = jest
6707
+ .spyOn(DataManager, 'getProtocolV2Resources')
6708
+ .mockReturnValue(stable as any);
6709
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6710
+ Promise.resolve({
6711
+ name: `${resource.type}.okpkg`,
6712
+ binary: new ArrayBuffer(resource.size),
6713
+ devicePath: `vol0:/${resource.type}.okpkg`,
6714
+ })
6715
+ );
6716
+
6717
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6718
+
6719
+ expect(bundles).toHaveLength(1);
6720
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6721
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6722
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6723
+ resourcesSpy.mockRestore();
6724
+ });
6725
+
6726
+ test('does not resolve boot resources when no resource target is selected', async () => {
6727
+ const method = new FirmwareUpdateV4({
6728
+ id: 1,
6729
+ payload: { method: 'firmwareUpdateV4', platform: 'web' },
6730
+ });
6731
+ method.init();
6732
+ const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6733
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6734
+
6735
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6736
+
6737
+ expect(configSpy).not.toHaveBeenCalled();
6738
+ expect(downloadSpy).not.toHaveBeenCalled();
6739
+ });
6740
+
6741
+ test('includes startup resources in the complete resource target', async () => {
6742
+ const bytes = new Uint8Array([1, 2, 3, 4]);
6743
+ const binary = bytes.buffer as ArrayBuffer;
6744
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6745
+ const resource = {
6746
+ required: false as const,
6747
+ target: 'RES' as const,
6748
+ files: [
6749
+ {
6750
+ name: 'bootloader_crest.bin',
6751
+ url: 'https://example.com/bootloader_crest.bin',
6752
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6753
+ size: bytes.byteLength,
6754
+ fileHash,
6755
+ },
6756
+ ],
6757
+ };
6758
+ const method = new FirmwareUpdateV4({
6759
+ id: 1,
6760
+ payload: {
6761
+ method: 'firmwareUpdateV4',
6762
+ platform: 'web',
6763
+ targetsToUpdate: ['resource'],
6764
+ },
6765
+ });
6766
+ method.init();
6767
+ (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
6768
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6769
+ jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6770
+
6771
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([
6772
+ {
6773
+ name: 'bootloader_crest.bin',
6774
+ binary,
6775
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6776
+ },
6777
+ ]);
6778
+ });
6779
+
6780
+ test('rejects a manually supplied resource file when its manifest hash does not match', () => {
6781
+ const method = new FirmwareUpdateV4({
6782
+ id: 1,
6783
+ payload: {
6784
+ method: 'firmwareUpdateV4',
6785
+ platform: 'web',
6786
+ resourceFiles: [
6787
+ {
6788
+ binary: new Uint8Array([1, 2, 3]).buffer,
6789
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6790
+ size: 3,
6791
+ fileHash: '00'.repeat(32),
6792
+ },
6793
+ ],
6794
+ },
6249
6795
  });
6796
+ method.init();
6250
6797
 
6251
- await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6252
- expect(readChunkLengths.length).toBeGreaterThan(1);
6253
- expect(Math.max(...readChunkLengths)).toBe(900);
6798
+ expect(() => (method as any).prepareExplicitProtocolV2ResourceFiles()).toThrow(
6799
+ 'SHA-256 mismatch'
6800
+ );
6254
6801
  });
6255
6802
  });
6256
6803
 
@@ -6373,330 +6920,249 @@ describe('Protocol V2 protected method execution', () => {
6373
6920
  reason: 'device-management',
6374
6921
  completion: 'operation-completed',
6375
6922
  deviceOnly: true,
6376
- operation: 'change-label',
6377
- });
6378
- });
6379
-
6380
- test.each([
6381
- ['deviceInfoGet', DeviceInfoGet],
6382
- ['deviceStatusGet', DeviceStatusGet],
6383
- ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6384
- const method = new MethodClass({
6385
- id: 1,
6386
- payload: { method: _name },
6387
- } as any);
6388
- method.init();
6389
-
6390
- expect(method.unlockPolicy).toBe('none');
6391
- });
6392
-
6393
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6394
- const calls: string[] = [];
6395
- const method = {
6396
- name: 'testBusinessMethod',
6397
- unlockPolicy: 'retry-on-locked',
6398
- protocolV2UiInteraction: { reason: 'settings-page' },
6399
- run: jest
6400
- .fn()
6401
- .mockImplementationOnce(() => {
6402
- calls.push('run-1');
6403
- return Promise.reject(deviceLockedError());
6404
- })
6405
- .mockImplementationOnce(() => {
6406
- calls.push('run-2');
6407
- return Promise.resolve({ message: 'ok' });
6408
- }),
6409
- };
6410
- const device = {
6411
- isProtocolV2: () => true,
6412
- unlockDevice: jest.fn(() => {
6413
- calls.push('unlock');
6414
- return Promise.resolve();
6415
- }),
6416
- };
6417
- const uiCoordinator = {
6418
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6419
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6420
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6421
- };
6422
-
6423
- await expect(
6424
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6425
- ).resolves.toEqual({ message: 'ok' });
6426
- expect(calls).toEqual([
6427
- 'method-prompt',
6428
- 'run-1',
6429
- 'unlock-prompt',
6430
- 'unlock',
6431
- 'method-prompt',
6432
- 'run-2',
6433
- ]);
6434
- });
6435
-
6436
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6437
- const calls: string[] = [];
6438
- const method = {
6439
- name: 'evmSignMessage',
6440
- payload: { passphraseState: 'hidden-state' },
6441
- useDevicePassphraseState: true,
6442
- unlockPolicy: 'retry-on-locked',
6443
- run: jest
6444
- .fn()
6445
- .mockImplementationOnce(() => {
6446
- calls.push('run-1');
6447
- return Promise.reject(deviceLockedError());
6448
- })
6449
- .mockImplementationOnce(() => {
6450
- calls.push('run-2');
6451
- return Promise.resolve({ message: 'ok' });
6452
- }),
6453
- };
6454
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6455
- if (requestType === 'ProtocolInfoRequest') {
6456
- calls.push('negotiate-session');
6457
- return Promise.resolve({ message: { version: 2 } });
6458
- }
6459
- if (requestType === 'DeviceSessionGet') {
6460
- calls.push('resume-hidden-session');
6461
- expect(request).toEqual({ session_id: 'hidden-session' });
6462
- return Promise.resolve({
6463
- message: {
6464
- session_id: 'hidden-session',
6465
- btc_test_address: 'hidden-state',
6466
- },
6467
- });
6468
- }
6469
- throw new Error(`Unexpected request: ${requestType}`);
6470
- });
6471
- const device = {
6472
- features: { unlocked: true, passphraseProtection: true },
6473
- passphraseState: 'hidden-state',
6474
- commands: { typedCall },
6475
- isProtocolV2: () => true,
6476
- unlockDevice: jest.fn(() => {
6477
- calls.push('unlock');
6478
- return Promise.resolve();
6479
- }),
6480
- getCurrentPassphraseProtection: () => true,
6481
- getInternalState: () => 'hidden-session',
6482
- clearInternalState: jest.fn(),
6483
- getCurrentDeviceId: () => 'wallet-device-id',
6484
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6485
- };
6486
-
6487
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
- message: 'ok',
6489
- });
6490
- expect(calls).toEqual([
6491
- 'run-1',
6492
- 'unlock',
6493
- 'negotiate-session',
6494
- 'resume-hidden-session',
6495
- 'validate-hidden-session',
6496
- 'run-2',
6497
- ]);
6923
+ operation: 'change-label',
6924
+ });
6498
6925
  });
6499
6926
 
6500
- test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6501
- const initialError = deviceLockedError();
6502
- const restoreError = new Error('Invalid wallet session');
6503
- const method = {
6504
- name: 'evmSignMessage',
6505
- payload: { passphraseState: 'hidden-state' },
6506
- useDevicePassphraseState: true,
6507
- unlockPolicy: 'retry-on-locked',
6508
- run: jest.fn().mockRejectedValueOnce(initialError),
6509
- };
6510
- const typedCall = jest.fn((requestType: string) => {
6511
- if (requestType === 'ProtocolInfoRequest') {
6512
- return Promise.resolve({ message: { version: 2 } });
6513
- }
6514
- if (requestType === 'DeviceSessionGet') {
6515
- return Promise.reject(restoreError);
6516
- }
6517
- throw new Error(`Unexpected request: ${requestType}`);
6518
- });
6519
- const device = {
6520
- features: { unlocked: true, passphraseProtection: true },
6521
- passphraseState: 'hidden-state',
6522
- commands: { typedCall },
6523
- isProtocolV2: () => true,
6524
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6525
- getCurrentPassphraseProtection: () => true,
6526
- getInternalState: () => 'hidden-session',
6527
- clearInternalState: jest.fn(),
6528
- getCurrentDeviceId: () => 'wallet-device-id',
6529
- updateInternalState: jest.fn(),
6530
- };
6927
+ test.each([
6928
+ ['deviceInfoGet', DeviceInfoGet],
6929
+ ['deviceStatusGet', DeviceStatusGet],
6930
+ ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6931
+ const method = new MethodClass({
6932
+ id: 1,
6933
+ payload: { method: _name },
6934
+ } as any);
6935
+ method.init();
6531
6936
 
6532
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6533
- expect(method.run).toHaveBeenCalledTimes(1);
6534
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6535
- expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6536
- expect(device.updateInternalState).not.toHaveBeenCalled();
6937
+ expect(method.unlockPolicy).toBe('none');
6537
6938
  });
6538
6939
 
6539
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6940
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6540
6941
  const calls: string[] = [];
6942
+ const features = { unlocked: true };
6541
6943
  const method = {
6542
- name: 'evmSignMessage',
6543
- payload: { passphraseState: 'hidden-state' },
6944
+ name: 'confluxSignMessageCIP23',
6945
+ unlockPolicy: 'none',
6544
6946
  useDevicePassphraseState: true,
6545
- unlockPolicy: 'retry-on-locked',
6947
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6546
6948
  run: jest.fn(() => {
6547
6949
  calls.push('run');
6548
- return Promise.resolve({ message: 'ok' });
6950
+ return Promise.resolve({ address: '0x1' });
6549
6951
  }),
6550
6952
  };
6551
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6552
- if (requestType === 'ProtocolInfoRequest') {
6553
- calls.push('negotiate-session');
6554
- return Promise.resolve({ message: { version: 2 } });
6555
- }
6556
- if (requestType === 'DeviceSessionGet') {
6557
- calls.push('resume-hidden-session');
6558
- expect(request).toEqual({ session_id: 'hidden-session' });
6559
- return Promise.resolve({
6560
- message: {
6561
- session_id: 'hidden-session',
6562
- btc_test_address: 'hidden-state',
6563
- },
6564
- });
6565
- }
6566
- throw new Error(`Unexpected request: ${requestType}`);
6567
- });
6568
- const features = {
6569
- unlocked: false,
6570
- unlockedAttachPin: true,
6571
- passphraseProtection: true,
6572
- };
6573
6953
  const device = {
6574
6954
  features,
6575
- passphraseState: 'hidden-state',
6576
- commands: { typedCall },
6955
+ commands: {
6956
+ typedCall: jest.fn(() => {
6957
+ calls.push('status');
6958
+ return Promise.resolve({ message: { unlocked: true } });
6959
+ }),
6960
+ },
6577
6961
  isProtocolV2: () => true,
6578
- unlockDevice: jest.fn(() => {
6579
- calls.push('unlock-main');
6580
- features.unlocked = true;
6581
- features.unlockedAttachPin = false;
6582
- return Promise.resolve();
6962
+ isBootloader: () => false,
6963
+ isRomloader: () => false,
6964
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6965
+ features.unlocked = status.unlocked === true;
6966
+ return features;
6583
6967
  }),
6584
- getCurrentPassphraseProtection: () => true,
6585
- getInternalState: () => 'hidden-session',
6586
- clearInternalState: jest.fn(),
6587
- getCurrentDeviceId: () => 'wallet-device-id',
6588
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6968
+ unlockDevice: jest.fn(),
6969
+ };
6970
+ const uiCoordinator = {
6971
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6972
+ enterUnlockInteraction: jest.fn(),
6589
6973
  };
6590
6974
 
6591
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6592
- message: 'ok',
6593
- });
6594
- expect(device.unlockDevice).toHaveBeenCalledWith();
6595
- expect(calls).toEqual([
6596
- 'unlock-main',
6597
- 'negotiate-session',
6598
- 'resume-hidden-session',
6599
- 'validate-hidden-session',
6600
- 'run',
6601
- ]);
6975
+ await expect(
6976
+ runMethodWithUnlockPolicy(method as any, device as any, {
6977
+ uiCoordinator: uiCoordinator as any,
6978
+ })
6979
+ ).resolves.toEqual({ address: '0x1' });
6980
+
6981
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6982
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6983
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6984
+ expect(method.run).toHaveBeenCalledTimes(1);
6602
6985
  });
6603
6986
 
6604
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6987
+ test('validates the fresh device identity before starting unlock', async () => {
6988
+ const calls: string[] = [];
6989
+ const identityError = new Error('Unexpected device');
6605
6990
  const method = {
6606
- name: 'evmGetAddress',
6607
- payload: { useEmptyPassphrase: true },
6991
+ name: 'evmSignMessage',
6992
+ unlockPolicy: 'none',
6608
6993
  useDevicePassphraseState: true,
6609
- unlockPolicy: 'retry-on-locked',
6610
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6994
+ run: jest.fn(),
6611
6995
  };
6996
+ const features = { unlocked: false };
6612
6997
  const device = {
6613
- features: { unlocked: false },
6614
- passphraseState: 'stale-hidden-state',
6998
+ features,
6999
+ commands: {
7000
+ typedCall: jest.fn(() => {
7001
+ calls.push('status');
7002
+ return Promise.resolve({ message: { unlocked: false } });
7003
+ }),
7004
+ },
6615
7005
  isProtocolV2: () => true,
6616
- unlockDevice: jest.fn().mockResolvedValue(undefined),
7006
+ isBootloader: () => false,
7007
+ isRomloader: () => false,
7008
+ updateProtocolV2Status: jest.fn(() => features),
7009
+ unlockDevice: jest.fn(),
6617
7010
  };
6618
7011
 
6619
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6620
- address: 'standard-wallet-address',
6621
- });
6622
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6623
- expect(method.run).toHaveBeenCalledTimes(1);
7012
+ await expect(
7013
+ runMethodWithUnlockPolicy(method as any, device as any, {
7014
+ afterStatusBeforeUnlock: () => {
7015
+ calls.push('identity');
7016
+ throw identityError;
7017
+ },
7018
+ })
7019
+ ).rejects.toBe(identityError);
7020
+
7021
+ expect(calls).toEqual(['status', 'identity']);
7022
+ expect(device.unlockDevice).not.toHaveBeenCalled();
7023
+ expect(method.run).not.toHaveBeenCalled();
6624
7024
  });
6625
7025
 
6626
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
7026
+ test('unlocks before business and never replays the callback', async () => {
6627
7027
  const calls: string[] = [];
7028
+ const features = { unlocked: true };
6628
7029
  const method = {
6629
- name: 'deviceSettingsPageShow',
7030
+ name: 'evmSignMessage',
6630
7031
  unlockPolicy: 'unlock-before-run',
6631
- protocolV2UiInteraction: { reason: 'settings-page' },
7032
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6632
7033
  run: jest.fn(() => {
6633
7034
  calls.push('run');
6634
- return Promise.resolve({ message: 'ok' });
7035
+ return Promise.resolve({ signature: 'signed' });
6635
7036
  }),
6636
7037
  };
6637
- const features = { unlocked: true };
6638
7038
  const device = {
6639
7039
  features,
6640
7040
  commands: {
6641
7041
  typedCall: jest.fn(() => {
6642
- calls.push('refresh-status');
7042
+ calls.push('status');
6643
7043
  return Promise.resolve({ message: { unlocked: false } });
6644
7044
  }),
6645
7045
  },
6646
7046
  isProtocolV2: () => true,
7047
+ isBootloader: () => false,
7048
+ isRomloader: () => false,
6647
7049
  updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6648
- features.unlocked = status.unlocked ?? features.unlocked;
7050
+ features.unlocked = status.unlocked === true;
6649
7051
  return features;
6650
7052
  }),
6651
7053
  unlockDevice: jest.fn(() => {
6652
7054
  calls.push('unlock');
6653
7055
  features.unlocked = true;
6654
- return Promise.resolve();
7056
+ return Promise.resolve(features);
6655
7057
  }),
6656
7058
  };
6657
7059
  const uiCoordinator = {
6658
7060
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6659
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6660
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
7061
+ enterUnlockInteraction: jest.fn(() => {
7062
+ calls.push('unlock-prompt');
7063
+ return undefined;
7064
+ }),
6661
7065
  };
6662
7066
 
6663
7067
  await expect(
6664
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6665
- ).resolves.toEqual({ message: 'ok' });
6666
- expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6667
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
7068
+ runMethodWithUnlockPolicy(method as any, device as any, {
7069
+ uiCoordinator: uiCoordinator as any,
7070
+ prepare: () => {
7071
+ calls.push('wallet-session');
7072
+ return Promise.resolve();
7073
+ },
7074
+ })
7075
+ ).resolves.toEqual({ signature: 'signed' });
7076
+
7077
+ expect(calls).toEqual([
7078
+ 'status',
7079
+ 'unlock-prompt',
7080
+ 'unlock',
7081
+ 'wallet-session',
7082
+ 'method-prompt',
7083
+ 'run',
7084
+ ]);
7085
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6668
7086
  expect(method.run).toHaveBeenCalledTimes(1);
6669
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6670
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6671
7087
  });
6672
7088
 
6673
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6674
- const features = { unlocked: false };
7089
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6675
7090
  const method = {
6676
- name: 'deviceSettings',
7091
+ name: 'evmGetAddress',
6677
7092
  unlockPolicy: 'unlock-before-run',
6678
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
7093
+ run: jest.fn(),
6679
7094
  };
6680
7095
  const device = {
6681
- features,
6682
- commands: {
6683
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6684
- },
7096
+ features: { unlocked: undefined },
7097
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6685
7098
  isProtocolV2: () => true,
6686
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6687
- features.unlocked = status.unlocked ?? features.unlocked;
6688
- return features;
6689
- }),
7099
+ isBootloader: () => false,
7100
+ isRomloader: () => false,
7101
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6690
7102
  unlockDevice: jest.fn(),
6691
7103
  };
6692
7104
 
6693
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6694
- message: 'ok',
7105
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
7106
+ errorCode: HardwareErrorCode.RuntimeError,
6695
7107
  });
6696
7108
  expect(device.unlockDevice).not.toHaveBeenCalled();
7109
+ expect(method.run).not.toHaveBeenCalled();
7110
+ });
7111
+
7112
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
7113
+ const error = deviceLockedError();
7114
+ const method = {
7115
+ name: 'evmSignMessage',
7116
+ unlockPolicy: 'unlock-before-run',
7117
+ run: jest.fn().mockRejectedValue(error),
7118
+ };
7119
+ const features = { unlocked: true };
7120
+ const device = {
7121
+ features,
7122
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
7123
+ isProtocolV2: () => true,
7124
+ isBootloader: () => false,
7125
+ isRomloader: () => false,
7126
+ updateProtocolV2Status: jest.fn(() => features),
7127
+ unlockDevice: jest.fn(),
7128
+ };
7129
+
7130
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
7131
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6697
7132
  expect(method.run).toHaveBeenCalledTimes(1);
6698
7133
  });
6699
7134
 
7135
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
7136
+ const features = { unlocked: true };
7137
+ const context = createProtocolV2UnlockContext();
7138
+ const firstMethod = {
7139
+ name: 'allNetworkGetAddress',
7140
+ unlockPolicy: 'unlock-before-run',
7141
+ run: jest.fn().mockResolvedValue({ root: true }),
7142
+ };
7143
+ const nestedMethod = {
7144
+ name: 'evmGetAddress',
7145
+ unlockPolicy: 'unlock-before-run',
7146
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
7147
+ };
7148
+ const device = {
7149
+ features,
7150
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
7151
+ isProtocolV2: () => true,
7152
+ isBootloader: () => false,
7153
+ isRomloader: () => false,
7154
+ updateProtocolV2Status: jest.fn(() => features),
7155
+ unlockDevice: jest.fn(),
7156
+ };
7157
+
7158
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
7159
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
7160
+
7161
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
7162
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
7163
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
7164
+ });
7165
+
6700
7166
  test.each(['bootloader', 'romloader'] as const)(
6701
7167
  'skips DeviceStatus and unlock in Protocol V2 %s mode',
6702
7168
  async mode => {
@@ -6714,7 +7180,7 @@ describe('Protocol V2 protected method execution', () => {
6714
7180
  unlockDevice: jest.fn(),
6715
7181
  };
6716
7182
 
6717
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
7183
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6718
7184
  message: 'updating',
6719
7185
  });
6720
7186
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6723,157 +7189,30 @@ describe('Protocol V2 protected method execution', () => {
6723
7189
  }
6724
7190
  );
6725
7191
 
6726
- test('infers a signing interaction before running a Protocol V2 business method', async () => {
6727
- const method = {
6728
- name: 'evmSignMessage',
6729
- params: { message: 'not-exposed-in-event' },
6730
- payload: {},
6731
- unlockPolicy: 'retry-on-locked',
6732
- run: jest.fn().mockResolvedValue({ signature: 'test' }),
6733
- };
6734
- const device = {
6735
- isProtocolV2: () => true,
6736
- unlockDevice: jest.fn(),
6737
- };
6738
- const uiCoordinator = {
6739
- enterMethodInteraction: jest.fn(),
6740
- enterUnlockInteraction: jest.fn(),
6741
- resumeMethodInteraction: jest.fn(),
6742
- };
6743
-
6744
- await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6745
-
6746
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6747
- request: 'button',
6748
- source: 'method-lifecycle',
6749
- reason: 'signing-confirmation',
6750
- completion: 'operation-completed',
6751
- deviceOnly: true,
6752
- operation: 'evmSignMessage',
6753
- });
6754
- });
6755
-
6756
- test('does not unlock a Protocol V1 device or a method without the policy', async () => {
7192
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6757
7193
  for (const [isProtocolV2, unlockPolicy] of [
6758
- [false, 'retry-on-locked'],
7194
+ [false, 'unlock-before-run'],
6759
7195
  [true, 'none'],
6760
7196
  ] as const) {
6761
- const error = deviceLockedError();
6762
7197
  const method = {
6763
7198
  unlockPolicy,
6764
- run: jest.fn().mockRejectedValue(error),
7199
+ useDevicePassphraseState: false,
7200
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6765
7201
  };
6766
7202
  const device = {
7203
+ commands: { typedCall: jest.fn() },
6767
7204
  isProtocolV2: () => isProtocolV2,
6768
7205
  unlockDevice: jest.fn(),
6769
7206
  };
6770
7207
 
6771
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
7208
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
7209
+ message: 'ok',
7210
+ });
7211
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6772
7212
  expect(device.unlockDevice).not.toHaveBeenCalled();
6773
7213
  expect(method.run).toHaveBeenCalledTimes(1);
6774
7214
  }
6775
7215
  });
6776
-
6777
- test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6778
- const method = {
6779
- name: 'uploadPortfolio',
6780
- unlockPolicy: 'none',
6781
- protocolV2UiMode: 'none',
6782
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6783
- };
6784
- const device = {
6785
- features: { unlocked: false },
6786
- isProtocolV2: () => true,
6787
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6788
- };
6789
- const uiCoordinator = {
6790
- enterMethodInteraction: jest.fn(),
6791
- enterUnlockInteraction: jest.fn(),
6792
- resumeMethodInteraction: jest.fn(),
6793
- };
6794
-
6795
- await expect(
6796
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6797
- ).resolves.toEqual({ message: 'ok' });
6798
- expect(device.unlockDevice).not.toHaveBeenCalled();
6799
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6800
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6801
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6802
- });
6803
-
6804
- test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6805
- const error = deviceLockedError();
6806
- const method = {
6807
- name: 'uploadPortfolio',
6808
- unlockPolicy: 'none',
6809
- run: jest.fn().mockRejectedValue(error),
6810
- };
6811
- const device = {
6812
- features: { unlocked: true },
6813
- isProtocolV2: () => true,
6814
- unlockDevice: jest.fn(),
6815
- };
6816
-
6817
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6818
- expect(method.run).toHaveBeenCalledTimes(1);
6819
- expect(device.unlockDevice).not.toHaveBeenCalled();
6820
- });
6821
-
6822
- test('does not retry when unlock fails or when the retry is still locked', async () => {
6823
- const initialError = deviceLockedError();
6824
- const unlockError = new Error('PIN cancelled');
6825
- const unlockFailMethod = {
6826
- unlockPolicy: 'retry-on-locked',
6827
- run: jest.fn().mockRejectedValue(initialError),
6828
- };
6829
- const unlockFailDevice = {
6830
- isProtocolV2: () => true,
6831
- unlockDevice: jest.fn().mockRejectedValue(unlockError),
6832
- };
6833
- const unlockFailCoordinator = {
6834
- enterMethodInteraction: jest.fn(),
6835
- enterUnlockInteraction: jest.fn(),
6836
- resumeMethodInteraction: jest.fn(),
6837
- };
6838
-
6839
- await expect(
6840
- runMethodWithUnlockRetry(
6841
- unlockFailMethod as any,
6842
- unlockFailDevice as any,
6843
- unlockFailCoordinator as any
6844
- )
6845
- ).rejects.toBe(unlockError);
6846
- expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6847
- expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6848
- expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6849
-
6850
- const retryError = deviceLockedError();
6851
- const retryFailMethod = {
6852
- unlockPolicy: 'retry-on-locked',
6853
- run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6854
- };
6855
- const retryFailDevice = {
6856
- isProtocolV2: () => true,
6857
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6858
- };
6859
- const retryFailCoordinator = {
6860
- enterMethodInteraction: jest.fn(),
6861
- enterUnlockInteraction: jest.fn(),
6862
- resumeMethodInteraction: jest.fn(),
6863
- };
6864
-
6865
- await expect(
6866
- runMethodWithUnlockRetry(
6867
- retryFailMethod as any,
6868
- retryFailDevice as any,
6869
- retryFailCoordinator as any
6870
- )
6871
- ).rejects.toBe(retryError);
6872
- expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6873
- expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6874
- expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6875
- expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6876
- });
6877
7216
  });
6878
7217
 
6879
7218
  describe('Protocol V2 current low-level methods', () => {
@@ -7005,7 +7344,9 @@ describe('Protocol V2 current low-level methods', () => {
7005
7344
 
7006
7345
  await method.run();
7007
7346
 
7008
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
7347
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
7348
+ eventless_wallet_session: true,
7349
+ });
7009
7350
  });
7010
7351
 
7011
7352
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {
@@ -7081,6 +7422,29 @@ describe('Protocol V2 current low-level methods', () => {
7081
7422
 
7082
7423
  await expect(method.run()).resolves.toBe(features);
7083
7424
  expect(unlockDevice).toHaveBeenCalledTimes(1);
7425
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
7426
+ emitUiEvent: false,
7427
+ });
7428
+ });
7429
+
7430
+ test('forwards an explicit PIN type when unlocking a Protocol V2 device', async () => {
7431
+ const method = new DeviceUnlock({
7432
+ id: 1,
7433
+ payload: {
7434
+ method: 'deviceUnlock',
7435
+ pinType: DeviceSessionPinType.Any,
7436
+ },
7437
+ });
7438
+ method.init();
7439
+
7440
+ const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
7441
+ (method as any).device = { unlockDevice };
7442
+
7443
+ await method.run();
7444
+
7445
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, {
7446
+ emitUiEvent: false,
7447
+ });
7084
7448
  });
7085
7449
 
7086
7450
  test.each([
@@ -7339,7 +7703,10 @@ describe('Protocol V2 file write method', () => {
7339
7703
  append: false,
7340
7704
  ui_percentage: 100,
7341
7705
  },
7342
- { timeoutMs: undefined }
7706
+ expect.objectContaining({
7707
+ timeoutMs: undefined,
7708
+ onWriteCompleted: expect.any(Function),
7709
+ })
7343
7710
  );
7344
7711
  expect(method.postMessage).toHaveBeenCalledWith({
7345
7712
  event: 'UI_EVENT',
@@ -7388,7 +7755,10 @@ describe('Protocol V2 file write method', () => {
7388
7755
  append: false,
7389
7756
  ui_percentage: 0,
7390
7757
  },
7391
- { timeoutMs: undefined }
7758
+ expect.objectContaining({
7759
+ timeoutMs: undefined,
7760
+ onWriteCompleted: expect.any(Function),
7761
+ })
7392
7762
  );
7393
7763
  expect(typedCall).toHaveBeenNthCalledWith(
7394
7764
  2,
@@ -7405,7 +7775,10 @@ describe('Protocol V2 file write method', () => {
7405
7775
  append: false,
7406
7776
  ui_percentage: 100,
7407
7777
  },
7408
- { timeoutMs: undefined }
7778
+ expect.objectContaining({
7779
+ timeoutMs: undefined,
7780
+ onWriteCompleted: expect.any(Function),
7781
+ })
7409
7782
  );
7410
7783
  expect(result).toMatchObject({
7411
7784
  path: 'vol1:test.bin',