@onekeyfe/hd-core 1.2.0-alpha.10 → 1.2.0-alpha.11

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 (98) hide show
  1. package/__tests__/DeviceCommands.test.ts +25 -0
  2. package/__tests__/RequestQueue.test.ts +34 -0
  3. package/__tests__/pro2Wallpaper.test.ts +50 -0
  4. package/__tests__/protocol-v2.test.ts +426 -60
  5. package/dist/api/BaseMethod.d.ts +4 -0
  6. package/dist/api/BaseMethod.d.ts.map +1 -1
  7. package/dist/api/FileWrite.d.ts.map +1 -1
  8. package/dist/api/FirmwareUpdateV4.d.ts +0 -4
  9. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  10. package/dist/api/GetPassphraseState.d.ts +1 -6
  11. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  12. package/dist/api/UploadPortfolio.d.ts +18 -0
  13. package/dist/api/UploadPortfolio.d.ts.map +1 -0
  14. package/dist/api/index.d.ts +5 -0
  15. package/dist/api/index.d.ts.map +1 -1
  16. package/dist/api/protocol-v2/DeviceSessionGet.d.ts +1 -4
  17. package/dist/api/protocol-v2/DeviceSessionGet.d.ts.map +1 -1
  18. package/dist/api/protocol-v2/DeviceSettingsGet.d.ts +6 -0
  19. package/dist/api/protocol-v2/DeviceSettingsGet.d.ts.map +1 -0
  20. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts +16 -0
  21. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts.map +1 -0
  22. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts +12 -0
  23. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts.map +1 -0
  24. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +26 -0
  25. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -0
  26. package/dist/core/RequestQueue.d.ts +2 -0
  27. package/dist/core/RequestQueue.d.ts.map +1 -1
  28. package/dist/core/index.d.ts +1 -0
  29. package/dist/core/index.d.ts.map +1 -1
  30. package/dist/device/Device.d.ts.map +1 -1
  31. package/dist/device/DeviceCommands.d.ts +4 -4
  32. package/dist/device/DeviceCommands.d.ts.map +1 -1
  33. package/dist/events/call.d.ts +7 -0
  34. package/dist/events/call.d.ts.map +1 -1
  35. package/dist/events/core.d.ts +2 -2
  36. package/dist/events/core.d.ts.map +1 -1
  37. package/dist/events/iframe.d.ts +1 -0
  38. package/dist/events/iframe.d.ts.map +1 -1
  39. package/dist/index.d.ts +76 -21
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +694 -253
  42. package/dist/inject.d.ts +3 -2
  43. package/dist/inject.d.ts.map +1 -1
  44. package/dist/lowLevelInject.d.ts +2 -1
  45. package/dist/lowLevelInject.d.ts.map +1 -1
  46. package/dist/protocols/protocol-v2/unlockRetry.d.ts +9 -0
  47. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -0
  48. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  49. package/dist/topLevelInject.d.ts.map +1 -1
  50. package/dist/types/api/firmwareUpdate.d.ts +2 -0
  51. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  52. package/dist/types/api/getPassphraseState.d.ts +2 -10
  53. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  54. package/dist/types/api/index.d.ts +8 -2
  55. package/dist/types/api/index.d.ts.map +1 -1
  56. package/dist/types/api/protocolV2.d.ts +19 -4
  57. package/dist/types/api/protocolV2.d.ts.map +1 -1
  58. package/dist/utils/deviceFeaturesUtils.d.ts +0 -2
  59. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  60. package/dist/utils/index.d.ts +2 -0
  61. package/dist/utils/index.d.ts.map +1 -1
  62. package/dist/utils/patch.d.ts +1 -1
  63. package/dist/utils/patch.d.ts.map +1 -1
  64. package/dist/utils/pro2Wallpaper.d.ts +12 -0
  65. package/dist/utils/pro2Wallpaper.d.ts.map +1 -0
  66. package/package.json +4 -4
  67. package/src/api/BaseMethod.ts +18 -0
  68. package/src/api/FileWrite.ts +9 -1
  69. package/src/api/FirmwareUpdateV4.ts +21 -91
  70. package/src/api/GetPassphraseState.ts +9 -18
  71. package/src/api/UploadPortfolio.ts +37 -0
  72. package/src/api/index.ts +5 -0
  73. package/src/api/protocol-v2/DeviceSessionGet.ts +3 -14
  74. package/src/api/protocol-v2/DeviceSettingsGet.ts +16 -0
  75. package/src/api/protocol-v2/DeviceSettingsPageShow.ts +59 -0
  76. package/src/api/protocol-v2/DeviceSettingsSet.ts +39 -0
  77. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +160 -0
  78. package/src/core/RequestQueue.ts +19 -0
  79. package/src/core/index.ts +10 -1
  80. package/src/data/messages/messages-protocol-v2.json +92 -8
  81. package/src/device/Device.ts +17 -3
  82. package/src/device/DeviceCommands.ts +9 -3
  83. package/src/events/call.ts +6 -0
  84. package/src/events/core.ts +2 -0
  85. package/src/events/iframe.ts +1 -0
  86. package/src/index.ts +4 -0
  87. package/src/inject.ts +13 -0
  88. package/src/lowLevelInject.ts +3 -0
  89. package/src/protocols/protocol-v2/unlockRetry.ts +46 -0
  90. package/src/protocols/protocol-v2/walletSession.ts +14 -5
  91. package/src/topLevelInject.ts +2 -0
  92. package/src/types/api/firmwareUpdate.ts +12 -0
  93. package/src/types/api/getPassphraseState.ts +2 -11
  94. package/src/types/api/index.ts +12 -1
  95. package/src/types/api/protocolV2.ts +46 -3
  96. package/src/utils/deviceFeaturesUtils.ts +3 -8
  97. package/src/utils/index.ts +6 -0
  98. package/src/utils/pro2Wallpaper.ts +111 -0
@@ -7,12 +7,17 @@ import DnxSignTransaction from '../src/api/dynex/DnxSignTransaction';
7
7
  import DirList from '../src/api/DirList';
8
8
  import FileRead from '../src/api/FileRead';
9
9
  import FileWrite from '../src/api/FileWrite';
10
+ import UploadPortfolio from '../src/api/UploadPortfolio';
10
11
  import DeviceFactoryInfoGet from '../src/api/protocol-v2/DeviceFactoryInfoGet';
11
12
  import DeviceFactoryInfoSet from '../src/api/protocol-v2/DeviceFactoryInfoSet';
12
13
  import DeviceFirmwareUpdate from '../src/api/protocol-v2/DeviceFirmwareUpdate';
13
14
  import DeviceGetFirmwareUpdateStatus from '../src/api/protocol-v2/DeviceGetFirmwareUpdateStatus';
14
15
  import DeviceInfoGet from '../src/api/protocol-v2/DeviceInfoGet';
15
16
  import DeviceReboot from '../src/api/protocol-v2/DeviceReboot';
17
+ import DeviceSettingsGet from '../src/api/protocol-v2/DeviceSettingsGet';
18
+ import DeviceSettingsPageShow from '../src/api/protocol-v2/DeviceSettingsPageShow';
19
+ import DeviceSettingsSet from '../src/api/protocol-v2/DeviceSettingsSet';
20
+ import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
16
21
  import DeviceSessionGet from '../src/api/protocol-v2/DeviceSessionGet';
17
22
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
18
23
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
@@ -56,6 +61,7 @@ import {
56
61
  getProtocolV2WalletSession,
57
62
  refreshProtocolV2DeviceStatus,
58
63
  } from '../src/protocols/protocol-v2/walletSession';
64
+ import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
59
65
  import { buildProfileFromProtocolV2, buildProtocolV2FeaturesPayload } from '../src/deviceProfile';
60
66
  import {
61
67
  getDeviceType,
@@ -80,6 +86,153 @@ jest.mock('../src/data/config', () => ({
80
86
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
81
87
  }));
82
88
 
89
+ describe('DeviceUploadWallpaper', () => {
90
+ test('encodes, uploads and applies a Pro2 wallpaper', async () => {
91
+ const rgba = new Uint8Array(604 * 1024 * 4).fill(255);
92
+ const typedCall = jest.fn().mockImplementation((request, _response, params) => {
93
+ if (request === 'FilesystemDirMake') return { message: { message: 'directory ready' } };
94
+ if (request === 'FilesystemFileWrite') {
95
+ return { message: { processed_byte: params.file.offset + params.file.data.byteLength } };
96
+ }
97
+ if (request === 'SetWallpaper') return { message: { message: 'wallpaper applied' } };
98
+ throw new Error(`Unexpected request: ${request}`);
99
+ });
100
+ const method = new DeviceUploadWallpaper({
101
+ id: 1,
102
+ payload: { method: 'deviceUploadWallpaper', width: 604, height: 1024, rgba },
103
+ });
104
+ (method as any).device = stubDevice({ commands: { typedCall } });
105
+
106
+ method.init();
107
+ const result = await method.run();
108
+
109
+ expect(method.requireProtocolV2).toBe(true);
110
+ expect(method.unlockPolicy).toBe('retry-on-locked');
111
+ expect(typedCall).toHaveBeenNthCalledWith(
112
+ 1,
113
+ 'FilesystemDirMake',
114
+ 'Success',
115
+ { path: 'vol0:/wallpapers/user' }
116
+ );
117
+ const fileWriteCall = typedCall.mock.calls.find(call => call[0] === 'FilesystemFileWrite');
118
+ expect(fileWriteCall?.[2]).toMatchObject({
119
+ file: { path: expect.stringMatching(/^vol0:\/wallpapers\/user\/wallpaper-[a-f0-9]+\.bin$/) },
120
+ overwrite: true,
121
+ append: false,
122
+ });
123
+ expect(typedCall).toHaveBeenLastCalledWith('SetWallpaper', 'Success', {
124
+ target: 1,
125
+ path: result.path,
126
+ });
127
+ expect(result).toMatchObject({ colorFormat: 'RGB565', message: 'wallpaper applied' });
128
+ });
129
+
130
+ test('rejects unsafe filenames before device communication', async () => {
131
+ const method = new DeviceUploadWallpaper({
132
+ id: 1,
133
+ payload: {
134
+ method: 'deviceUploadWallpaper',
135
+ width: 604,
136
+ height: 1024,
137
+ rgba: new Uint8Array(604 * 1024 * 4),
138
+ fileName: '../wallpaper.bin',
139
+ },
140
+ });
141
+
142
+ expect(() => method.init()).toThrow('fileName');
143
+ });
144
+ });
145
+
146
+ describe('UploadPortfolio', () => {
147
+ test('stages the complete package before applying PortfolioUpdate', async () => {
148
+ const packageBytes = new Uint8Array([1, 2, 3]);
149
+ const typedCall = jest
150
+ .fn()
151
+ .mockResolvedValueOnce({ message: { processed_byte: 3 } })
152
+ .mockResolvedValueOnce({ message: { message: 'Portfolio updated' } });
153
+ const method = new UploadPortfolio({
154
+ id: 1,
155
+ payload: {
156
+ method: 'uploadPortfolio',
157
+ packageBytes,
158
+ },
159
+ });
160
+ (method as any).device = stubDevice({ commands: { typedCall } });
161
+ method.postMessage = jest.fn();
162
+
163
+ method.init();
164
+ const result = await method.run();
165
+
166
+ expect(typedCall).toHaveBeenNthCalledWith(
167
+ 1,
168
+ 'FilesystemFileWrite',
169
+ 'FilesystemFile',
170
+ {
171
+ file: {
172
+ path: 'vol1:/portfolio/portfolio.pfol.pending',
173
+ offset: 0,
174
+ total_size: 3,
175
+ data: packageBytes,
176
+ },
177
+ overwrite: true,
178
+ append: false,
179
+ ui_percentage: 100,
180
+ },
181
+ { timeoutMs: undefined }
182
+ );
183
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'PortfolioUpdate', 'Success', {});
184
+ expect(result).toMatchObject({
185
+ path: 'vol1:/portfolio/portfolio.pfol.pending',
186
+ processed_byte: 3,
187
+ portfolioUpdated: true,
188
+ });
189
+ });
190
+
191
+ test('does not apply PortfolioUpdate when staging fails', async () => {
192
+ const typedCall = jest.fn().mockRejectedValue(new Error('write failed'));
193
+ const method = new UploadPortfolio({
194
+ id: 1,
195
+ payload: {
196
+ method: 'uploadPortfolio',
197
+ packageBytes: new Uint8Array([1]),
198
+ },
199
+ });
200
+ (method as any).device = stubDevice({ commands: { typedCall } });
201
+
202
+ method.init();
203
+
204
+ await expect(method.run()).rejects.toThrow('write failed');
205
+ expect(typedCall).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ test('stops after the acknowledged chunk when the operation is aborted', async () => {
209
+ const packageBytes = new Uint8Array(4001);
210
+ const abortController = new AbortController();
211
+ const typedCall = jest.fn().mockImplementationOnce(async () => {
212
+ abortController.abort();
213
+ return { message: { processed_byte: 2048 } };
214
+ });
215
+ const method = new UploadPortfolio({
216
+ id: 1,
217
+ payload: {
218
+ method: 'uploadPortfolio',
219
+ packageBytes,
220
+ operationId: 'portfolio-1',
221
+ },
222
+ });
223
+ method.abortSignal = abortController.signal;
224
+ (method as any).device = stubDevice({ commands: { typedCall } });
225
+
226
+ method.init();
227
+
228
+ await expect(method.run()).rejects.toMatchObject({
229
+ errorCode: HardwareErrorCode.CallQueueActionCancelled,
230
+ });
231
+ expect(typedCall).toHaveBeenCalledTimes(1);
232
+ expect(typedCall).not.toHaveBeenCalledWith('PortfolioUpdate', 'Success', {});
233
+ });
234
+ });
235
+
83
236
  const descriptor = {
84
237
  id: 'ble-id',
85
238
  path: 'usb-path',
@@ -272,7 +425,6 @@ describe('Protocol V2 feature adapter', () => {
272
425
  await expect(
273
426
  getPassphraseState(device, {
274
427
  expectPassphraseState: 'state-2',
275
- allowCreateAttachPin: true,
276
428
  })
277
429
  ).rejects.toEqual(
278
430
  expect.objectContaining({
@@ -308,7 +460,7 @@ describe('Protocol V2 feature adapter', () => {
308
460
  expect(updateProtocolV2Features).not.toHaveBeenCalled();
309
461
  });
310
462
 
311
- test('deviceSessionGet maps sessionId and does not mutate wallet cache', async () => {
463
+ test('deviceSessionGet sends an empty request and does not mutate wallet cache', async () => {
312
464
  const typedCall = jest.fn().mockResolvedValue({
313
465
  type: 'DeviceSession',
314
466
  message: { session_id: 'new-session', btc_test_address: 'state-a' },
@@ -318,7 +470,6 @@ describe('Protocol V2 feature adapter', () => {
318
470
  payload: {
319
471
  method: 'deviceSessionGet',
320
472
  connectId: 'connect-id',
321
- sessionId: 'cached-session',
322
473
  },
323
474
  });
324
475
  method.init();
@@ -332,9 +483,7 @@ describe('Protocol V2 feature adapter', () => {
332
483
  session_id: 'new-session',
333
484
  btc_test_address: 'state-a',
334
485
  });
335
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
336
- session_id: 'cached-session',
337
- });
486
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
338
487
  expect(updateInternalState).not.toHaveBeenCalled();
339
488
  });
340
489
 
@@ -343,7 +492,7 @@ describe('Protocol V2 feature adapter', () => {
343
492
  const api = createCoreApi(call as any);
344
493
 
345
494
  await api.deviceStatusGet('connect-id', { retryCount: 1 });
346
- await api.deviceSessionGet('connect-id', { sessionId: 'cached-session' });
495
+ await api.deviceSessionGet('connect-id', { retryCount: 1 });
347
496
 
348
497
  expect(call).toHaveBeenNthCalledWith(1, {
349
498
  method: 'deviceStatusGet',
@@ -353,7 +502,7 @@ describe('Protocol V2 feature adapter', () => {
353
502
  expect(call).toHaveBeenNthCalledWith(2, {
354
503
  method: 'deviceSessionGet',
355
504
  connectId: 'connect-id',
356
- sessionId: 'cached-session',
505
+ retryCount: 1,
357
506
  });
358
507
  });
359
508
 
@@ -415,7 +564,7 @@ describe('Protocol V2 feature adapter', () => {
415
564
  expect(device.getInternalState()).toBeUndefined();
416
565
  });
417
566
 
418
- test('clears the selected Pro2 cache entry after invalid session rejection', async () => {
567
+ test('retries without the selected Pro2 cache entry after invalid session rejection', async () => {
419
568
  const device = Device.fromDescriptor({
420
569
  id: 'cache-device-3',
421
570
  path: 'cache-path-3',
@@ -433,12 +582,49 @@ describe('Protocol V2 feature adapter', () => {
433
582
  );
434
583
  device.passphraseState = 'state-a';
435
584
  preloadSessionCache('stable-device-3', 'state-a', 'session-a');
436
- (device as any).commands = {
437
- typedCall: jest.fn().mockRejectedValue(new Error('Failure_InvalidSession,no error message')),
438
- };
585
+ const typedCall = jest
586
+ .fn()
587
+ .mockRejectedValueOnce(new Error('Failure_ProcessError,Failure_InvalidSession'))
588
+ .mockResolvedValueOnce({
589
+ type: 'DeviceSession',
590
+ message: { session_id: 'session-b', btc_test_address: 'state-a' },
591
+ });
592
+ (device as any).commands = { typedCall };
593
+
594
+ await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
595
+ passphraseState: 'state-a',
596
+ newSession: 'session-b',
597
+ });
598
+ expect(typedCall.mock.calls).toEqual([
599
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
600
+ ['DeviceSessionGet', 'DeviceSession', {}],
601
+ ]);
602
+ expect(device.getInternalState()).toBe('session-b');
603
+ });
604
+
605
+ test('does not retry an invalid Pro2 session when no cached session was sent', async () => {
606
+ const device = Device.fromDescriptor({
607
+ id: 'cache-device-no-session',
608
+ path: 'cache-path-no-session',
609
+ protocolType: 'V2',
610
+ } as any);
611
+ (device as any).features = normalizeProtocolV2Features(
612
+ { ...descriptor, protocolType: 'V2' } as any,
613
+ {
614
+ status: {
615
+ device_id: 'stable-device-no-session',
616
+ unlocked: true,
617
+ passphrase_enabled: true,
618
+ },
619
+ }
620
+ );
621
+ const typedCall = jest
622
+ .fn()
623
+ .mockRejectedValue(new Error('Failure_ProcessError,Failure_InvalidSession'));
624
+ (device as any).commands = { typedCall };
439
625
 
440
626
  await expect(getProtocolV2WalletSession(device)).rejects.toThrow('Failure_InvalidSession');
441
- expect(device.getInternalState()).toBeUndefined();
627
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionGet', 'DeviceSession', {}]]);
442
628
  });
443
629
 
444
630
  test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
@@ -547,7 +733,7 @@ describe('Protocol V2 feature adapter', () => {
547
733
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
548
734
  });
549
735
 
550
- test('returns unified GetPassphraseState object payload for existing Pro devices', async () => {
736
+ test('returns passphrase state string for existing Pro devices', async () => {
551
737
  const features = {
552
738
  deviceId: 'pro-device-id',
553
739
  deviceType: 'pro',
@@ -580,12 +766,7 @@ describe('Protocol V2 feature adapter', () => {
580
766
  getCurrentPassphraseProtection: () => true,
581
767
  }) as any;
582
768
 
583
- await expect(method.run()).resolves.toEqual({
584
- passphraseState: 'state-pro',
585
- sessionId: 'session-pro',
586
- unlockedAttachPin: false,
587
- passphraseProtection: true,
588
- });
769
+ await expect(method.run()).resolves.toBe('state-pro');
589
770
  expect(updateInternalState).toHaveBeenCalledWith(
590
771
  true,
591
772
  'state-pro',
@@ -595,7 +776,7 @@ describe('Protocol V2 feature adapter', () => {
595
776
  );
596
777
  });
597
778
 
598
- test('uses features for GetPassphraseState response metadata', async () => {
779
+ test('returns passphrase state string for Pro2 devices', async () => {
599
780
  const features = {
600
781
  deviceId: null,
601
782
  deviceType: 'pro2',
@@ -630,12 +811,7 @@ describe('Protocol V2 feature adapter', () => {
630
811
  getCurrentPassphraseProtection: () => true,
631
812
  }) as any;
632
813
 
633
- await expect(method.run()).resolves.toEqual({
634
- passphraseState: 'state-pro2',
635
- sessionId: 'session-pro2',
636
- unlockedAttachPin: true,
637
- passphraseProtection: true,
638
- });
814
+ await expect(method.run()).resolves.toBe('state-pro2');
639
815
  expect(getFeatures).not.toHaveBeenCalled();
640
816
  });
641
817
 
@@ -673,12 +849,7 @@ describe('Protocol V2 feature adapter', () => {
673
849
  getCurrentPassphraseProtection: () => true,
674
850
  }) as any;
675
851
 
676
- await expect(method.run()).resolves.toEqual({
677
- passphraseState: 'state-pro2-new',
678
- sessionId: undefined,
679
- unlockedAttachPin: false,
680
- passphraseProtection: true,
681
- });
852
+ await expect(method.run()).resolves.toBe('state-pro2-new');
682
853
  expect(clearInternalState).toHaveBeenCalledTimes(1);
683
854
  expect(updateInternalState).toHaveBeenCalledWith(
684
855
  true,
@@ -1578,16 +1749,10 @@ describe('Protocol V2 feature adapter', () => {
1578
1749
  if (requestType === 'DeviceSessionAskPin') {
1579
1750
  return {
1580
1751
  type: 'DeviceSessionPinResult',
1581
- message: { unlocked: false, passphrase_protection: false },
1582
- };
1583
- }
1584
- if (requestType === 'DeviceStatusGet') {
1585
- return {
1586
- type: 'DeviceStatus',
1587
1752
  message: {
1588
- device_id: 'PRO2-DEVICE-ID',
1589
1753
  unlocked: true,
1590
- passphrase_enabled: true,
1754
+ unlocked_attach_pin: true,
1755
+ passphrase_protection: true,
1591
1756
  },
1592
1757
  };
1593
1758
  }
@@ -1608,22 +1773,24 @@ describe('Protocol V2 feature adapter', () => {
1608
1773
 
1609
1774
  const features = await device.unlockDevice();
1610
1775
 
1611
- expect(typedCall.mock.calls).toEqual([
1612
- ['DeviceSessionAskPin', 'DeviceSessionPinResult'],
1613
- ['DeviceStatusGet', 'DeviceStatus', {}],
1614
- ]);
1776
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionAskPin', 'DeviceSessionPinResult']]);
1615
1777
  expect(typedCall).not.toHaveBeenCalledWith('GetAddress', 'Address', expect.anything());
1616
1778
  expect(typedCall).not.toHaveBeenCalledWith('GetFeatures', 'Features', {});
1617
1779
  expect(features).toMatchObject({
1618
1780
  deviceType: 'pro2',
1619
- deviceId: 'PRO2-DEVICE-ID',
1620
1781
  firmwareVersion: '1.2.3',
1621
1782
  unlocked: true,
1783
+ unlockedAttachPin: true,
1622
1784
  passphraseProtection: true,
1623
1785
  });
1786
+ expect(features.raw?.protocolV2DeviceInfo?.status).toMatchObject({
1787
+ unlocked: true,
1788
+ unlocked_by_attach_to_pin: true,
1789
+ passphrase_enabled: true,
1790
+ });
1624
1791
  });
1625
1792
 
1626
- test('syncs Protocol V2 features passphrase state from DeviceStatusGet after unlock', async () => {
1793
+ test('syncs Protocol V2 features passphrase state from DeviceSessionPinResult after unlock', async () => {
1627
1794
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1628
1795
  (device as any).features = normalizeProtocolV2Features(
1629
1796
  { ...descriptor, protocolType: 'V2' } as any,
@@ -1637,20 +1804,10 @@ describe('Protocol V2 feature adapter', () => {
1637
1804
  if (requestType === 'DeviceSessionAskPin') {
1638
1805
  return {
1639
1806
  type: 'DeviceSessionPinResult',
1640
- message: {
1641
- unlocked: false,
1642
- unlocked_attach_pin: false,
1643
- passphrase_protection: false,
1644
- },
1645
- };
1646
- }
1647
- if (requestType === 'DeviceStatusGet') {
1648
- return {
1649
- type: 'DeviceStatus',
1650
1807
  message: {
1651
1808
  unlocked: true,
1652
- unlocked_by_attach_to_pin: true,
1653
- passphrase_enabled: true,
1809
+ unlocked_attach_pin: true,
1810
+ passphrase_protection: true,
1654
1811
  },
1655
1812
  };
1656
1813
  }
@@ -1660,7 +1817,9 @@ describe('Protocol V2 feature adapter', () => {
1660
1817
 
1661
1818
  await device.unlockDevice();
1662
1819
 
1820
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionAskPin', 'DeviceSessionPinResult']]);
1663
1821
  expect((device as any).profile).toBeUndefined();
1822
+ expect(device.features?.unlocked).toBe(true);
1664
1823
  expect(device.features?.passphraseProtection).toBe(true);
1665
1824
  expect(device.features?.unlockedAttachPin).toBe(true);
1666
1825
  });
@@ -2818,6 +2977,16 @@ describe('Protocol V2 firmware update targets', () => {
2818
2977
  payload: {
2819
2978
  method: 'firmwareUpdateV4',
2820
2979
  platform: 'web',
2980
+ targetsToUpdate: [
2981
+ 'boot',
2982
+ 'app_v1',
2983
+ 'app_v2',
2984
+ 'coprocessor',
2985
+ 'se01',
2986
+ 'se02',
2987
+ 'se03',
2988
+ 'se04',
2989
+ ],
2821
2990
  },
2822
2991
  });
2823
2992
  method.init();
@@ -2931,6 +3100,7 @@ describe('Protocol V2 firmware update targets', () => {
2931
3100
  payload: {
2932
3101
  method: 'firmwareUpdateV4',
2933
3102
  platform: 'web',
3103
+ targetsToUpdate: ['resource'],
2934
3104
  },
2935
3105
  });
2936
3106
  method.init();
@@ -2980,7 +3150,7 @@ describe('Protocol V2 firmware update targets', () => {
2980
3150
  getFirmwareLatestReleaseSpy.mockRestore();
2981
3151
  });
2982
3152
 
2983
- test('skips Pro2 firmware components when configured versions are already installed', async () => {
3153
+ test('does not download Pro2 firmware components that App did not select', async () => {
2984
3154
  const method = new FirmwareUpdateV4({
2985
3155
  id: 1,
2986
3156
  payload: {
@@ -3671,6 +3841,102 @@ describe('Protocol V2 reboot methods', () => {
3671
3841
  });
3672
3842
  });
3673
3843
 
3844
+ describe('Protocol V2 protected method execution', () => {
3845
+ const deviceLockedError = () =>
3846
+ Object.assign(new Error('Device locked'), {
3847
+ errorCode: HardwareErrorCode.DeviceLocked,
3848
+ });
3849
+
3850
+ test('unlocks and retries an opted-in Protocol V2 method once', async () => {
3851
+ const calls: string[] = [];
3852
+ const method = {
3853
+ unlockPolicy: 'retry-on-locked',
3854
+ run: jest
3855
+ .fn()
3856
+ .mockImplementationOnce(async () => {
3857
+ calls.push('run-1');
3858
+ throw deviceLockedError();
3859
+ })
3860
+ .mockImplementationOnce(async () => {
3861
+ calls.push('run-2');
3862
+ return { message: 'ok' };
3863
+ }),
3864
+ };
3865
+ const device = {
3866
+ isProtocolV2: () => true,
3867
+ unlockDevice: jest.fn(async () => {
3868
+ calls.push('unlock');
3869
+ }),
3870
+ };
3871
+
3872
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
3873
+ message: 'ok',
3874
+ });
3875
+ expect(calls).toEqual(['run-1', 'unlock', 'run-2']);
3876
+ });
3877
+
3878
+ test('does not unlock a Protocol V1 device or a method without the policy', async () => {
3879
+ for (const [isProtocolV2, unlockPolicy] of [
3880
+ [false, 'retry-on-locked'],
3881
+ [true, 'none'],
3882
+ ] as const) {
3883
+ const error = deviceLockedError();
3884
+ const method = {
3885
+ unlockPolicy,
3886
+ run: jest.fn(async () => {
3887
+ throw error;
3888
+ }),
3889
+ };
3890
+ const device = {
3891
+ isProtocolV2: () => isProtocolV2,
3892
+ unlockDevice: jest.fn(),
3893
+ };
3894
+
3895
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
3896
+ expect(device.unlockDevice).not.toHaveBeenCalled();
3897
+ expect(method.run).toHaveBeenCalledTimes(1);
3898
+ }
3899
+ });
3900
+
3901
+ test('does not retry when unlock fails or when the retry is still locked', async () => {
3902
+ const initialError = deviceLockedError();
3903
+ const unlockError = new Error('PIN cancelled');
3904
+ const unlockFailMethod = {
3905
+ unlockPolicy: 'retry-on-locked',
3906
+ run: jest.fn(async () => {
3907
+ throw initialError;
3908
+ }),
3909
+ };
3910
+ const unlockFailDevice = {
3911
+ isProtocolV2: () => true,
3912
+ unlockDevice: jest.fn(async () => {
3913
+ throw unlockError;
3914
+ }),
3915
+ };
3916
+
3917
+ await expect(
3918
+ runMethodWithUnlockRetry(unlockFailMethod as any, unlockFailDevice as any)
3919
+ ).rejects.toBe(unlockError);
3920
+ expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
3921
+
3922
+ const retryError = deviceLockedError();
3923
+ const retryFailMethod = {
3924
+ unlockPolicy: 'retry-on-locked',
3925
+ run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
3926
+ };
3927
+ const retryFailDevice = {
3928
+ isProtocolV2: () => true,
3929
+ unlockDevice: jest.fn(async () => undefined),
3930
+ };
3931
+
3932
+ await expect(
3933
+ runMethodWithUnlockRetry(retryFailMethod as any, retryFailDevice as any)
3934
+ ).rejects.toBe(retryError);
3935
+ expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
3936
+ expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
3937
+ });
3938
+ });
3939
+
3674
3940
  describe('Protocol V2 current low-level methods', () => {
3675
3941
  test('sends ProtocolInfoRequest from protocolInfoRequest', async () => {
3676
3942
  const typedCall = jest.fn().mockResolvedValue({ message: { version: 1 } });
@@ -3724,6 +3990,106 @@ describe('Protocol V2 current low-level methods', () => {
3724
3990
  expect(typedCall).toHaveBeenLastCalledWith('DeviceFactoryInfoGet', 'DeviceFactoryInfo', {});
3725
3991
  });
3726
3992
 
3993
+ test('gets Protocol V2 device settings', async () => {
3994
+ const typedCall = jest.fn().mockResolvedValue({ message: { brightness: 80 } });
3995
+ const method = new DeviceSettingsGet({
3996
+ id: 1,
3997
+ payload: { method: 'deviceSettingsGet' },
3998
+ });
3999
+ method.init();
4000
+ (method as any).device = stubDevice({ commands: { typedCall } });
4001
+
4002
+ await expect(method.run()).resolves.toEqual({ brightness: 80 });
4003
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsGet', 'DeviceSettings', {});
4004
+ });
4005
+
4006
+ test('sets Protocol V2 device settings without passphrase fields', async () => {
4007
+ const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4008
+ const method = new DeviceSettingsSet({
4009
+ id: 1,
4010
+ payload: {
4011
+ method: 'deviceSettingsSet',
4012
+ settings: {
4013
+ label: 'My Pro 2',
4014
+ brightness: 80,
4015
+ airgap_mode: true,
4016
+ passphrase_enable: true,
4017
+ },
4018
+ },
4019
+ });
4020
+ method.init();
4021
+ (method as any).device = stubDevice({ commands: { typedCall } });
4022
+
4023
+ await method.run();
4024
+
4025
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsSet', 'Success', {
4026
+ settings: {
4027
+ label: 'My Pro 2',
4028
+ brightness: 80,
4029
+ },
4030
+ });
4031
+ });
4032
+
4033
+ test('opens non-passphrase Protocol V2 settings pages', async () => {
4034
+ const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4035
+ const method = new DeviceSettingsPageShow({
4036
+ id: 1,
4037
+ payload: {
4038
+ method: 'deviceSettingsPageShow',
4039
+ page: 'DeviceAirgap',
4040
+ fieldName: 'airgap_mode',
4041
+ },
4042
+ });
4043
+ method.init();
4044
+ (method as any).device = stubDevice({ commands: { typedCall } });
4045
+
4046
+ await method.run();
4047
+
4048
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4049
+ page: 3,
4050
+ field_name: 'airgap_mode',
4051
+ });
4052
+ });
4053
+
4054
+ test('opens the Protocol V2 passphrase settings page', async () => {
4055
+ const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4056
+ const method = new DeviceSettingsPageShow({
4057
+ id: 1,
4058
+ payload: {
4059
+ method: 'deviceSettingsPageShow',
4060
+ page: 'DevicePassphrase',
4061
+ },
4062
+ });
4063
+ method.init();
4064
+ (method as any).device = stubDevice({ commands: { typedCall } });
4065
+
4066
+ await method.run();
4067
+
4068
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4069
+ page: 2,
4070
+ field_name: undefined,
4071
+ });
4072
+ });
4073
+
4074
+ test('marks Protocol V2 settings methods for unlock-on-locked retry', () => {
4075
+ const methods = [
4076
+ new DeviceSettingsGet({ id: 1, payload: { method: 'deviceSettingsGet' } }),
4077
+ new DeviceSettingsSet({
4078
+ id: 2,
4079
+ payload: { method: 'deviceSettingsSet', settings: { brightness: 80 } },
4080
+ }),
4081
+ new DeviceSettingsPageShow({
4082
+ id: 3,
4083
+ payload: { method: 'deviceSettingsPageShow', page: 'DevicePassphrase' },
4084
+ }),
4085
+ ];
4086
+
4087
+ methods.forEach(method => {
4088
+ method.init();
4089
+ expect(method.unlockPolicy).toBe('retry-on-locked');
4090
+ });
4091
+ });
4092
+
3727
4093
  test('sends FilesystemPermissionFix from filesystemPermissionFix', async () => {
3728
4094
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
3729
4095
  const method = new FilesystemPermissionFix({
@@ -4,7 +4,9 @@ import type { DeviceFirmwareRange } from '../types';
4
4
  import type { CoreMessage } from '../events';
5
5
  import type { RequestContext } from '../utils/tracing';
6
6
  import type { CoreContext } from '../core';
7
+ export type UnlockPolicy = 'none' | 'retry-on-locked';
7
8
  export declare abstract class BaseMethod<Params = undefined> {
9
+ abortSignal?: AbortSignal;
8
10
  responseID: number;
9
11
  device: Device;
10
12
  params: Params;
@@ -30,6 +32,8 @@ export declare abstract class BaseMethod<Params = undefined> {
30
32
  getPreWarmKey(): string;
31
33
  strictCheckDeviceSupport: boolean;
32
34
  requireProtocolV2: boolean;
35
+ unlockPolicy: UnlockPolicy;
36
+ protected throwIfAborted(): void;
33
37
  postMessage: (message: CoreMessage) => void;
34
38
  context?: CoreContext;
35
39
  temporarySafetyCheckPrompted: boolean;