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

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