@onekeyfe/hd-core 1.2.0-alpha.96 → 1.2.0-alpha.97

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 (89) hide show
  1. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +84 -404
  2. package/__tests__/firmware-update/device-update-bootloader.test.ts +2 -6
  3. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -9
  4. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +112 -0
  5. package/__tests__/firmware-update/firmware-update-plan.test.ts +70 -131
  6. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +1 -53
  7. package/__tests__/protocol-v2-resources.test.ts +232 -148
  8. package/__tests__/protocol-v2.test.ts +517 -1106
  9. package/dist/api/CheckAllFirmwareRelease.d.ts +3 -2
  10. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  11. package/dist/api/CheckBLEFirmwareRelease.d.ts +2 -3
  12. package/dist/api/CheckBLEFirmwareRelease.d.ts.map +1 -1
  13. package/dist/api/CheckBootloaderRelease.d.ts +1 -2
  14. package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
  15. package/dist/api/CheckFirmwareRelease.d.ts +2 -3
  16. package/dist/api/CheckFirmwareRelease.d.ts.map +1 -1
  17. package/dist/api/FirmwareUpdateV2.d.ts +0 -1
  18. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  19. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  20. package/dist/api/FirmwareUpdateV4.d.ts +8 -6
  21. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  22. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  23. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  24. package/dist/api/firmware/FirmwareHostBinding.d.ts +1 -1
  25. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -1
  26. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  27. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +1 -48
  28. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -1
  29. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -1
  30. package/dist/api/firmware/getBinary.d.ts +1 -0
  31. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  32. package/dist/data-manager/DataManager.d.ts +3 -4
  33. package/dist/data-manager/DataManager.d.ts.map +1 -1
  34. package/dist/index.d.ts +111 -103
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +849 -1313
  37. package/dist/protocols/protocol-v2/resources.d.ts +28 -10
  38. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  39. package/dist/types/api/checkAllFirmwareRelease.d.ts +8 -20
  40. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  41. package/dist/types/api/checkBLEFirmwareRelease.d.ts +13 -2
  42. package/dist/types/api/checkBLEFirmwareRelease.d.ts.map +1 -1
  43. package/dist/types/api/checkBootloaderRelease.d.ts +6 -2
  44. package/dist/types/api/checkBootloaderRelease.d.ts.map +1 -1
  45. package/dist/types/api/checkFirmwareRelease.d.ts +13 -2
  46. package/dist/types/api/checkFirmwareRelease.d.ts.map +1 -1
  47. package/dist/types/api/checkFirmwareTypeAvailable.d.ts +2 -2
  48. package/dist/types/api/checkFirmwareTypeAvailable.d.ts.map +1 -1
  49. package/dist/types/api/export.d.ts +1 -1
  50. package/dist/types/api/export.d.ts.map +1 -1
  51. package/dist/types/api/firmwareUpdate.d.ts +11 -3
  52. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  53. package/dist/types/settings.d.ts +36 -35
  54. package/dist/types/settings.d.ts.map +1 -1
  55. package/package.json +4 -4
  56. package/src/api/CheckAllFirmwareRelease.ts +80 -108
  57. package/src/api/CheckBLEFirmwareRelease.ts +5 -37
  58. package/src/api/CheckBootloaderRelease.ts +3 -35
  59. package/src/api/CheckFirmwareRelease.ts +7 -35
  60. package/src/api/FirmwareUpdateV2.ts +3 -15
  61. package/src/api/FirmwareUpdateV3.ts +7 -29
  62. package/src/api/FirmwareUpdateV4.ts +407 -824
  63. package/src/api/UploadPortfolio.ts +0 -18
  64. package/src/api/device/DeviceUpdateBootloader.ts +5 -26
  65. package/src/api/firmware/FirmwareHostBinding.ts +3 -16
  66. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +2 -0
  67. package/src/api/firmware/FirmwareUpdatePlan.ts +88 -226
  68. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +3 -10
  69. package/src/data-manager/DataManager.ts +19 -35
  70. package/src/index.ts +0 -10
  71. package/src/protocols/protocol-v2/resources.ts +341 -157
  72. package/src/types/api/checkAllFirmwareRelease.ts +12 -27
  73. package/src/types/api/checkBLEFirmwareRelease.ts +13 -4
  74. package/src/types/api/checkBootloaderRelease.ts +6 -2
  75. package/src/types/api/checkFirmwareRelease.ts +13 -2
  76. package/src/types/api/checkFirmwareTypeAvailable.ts +2 -2
  77. package/src/types/api/export.ts +1 -6
  78. package/src/types/api/firmwareUpdate.ts +16 -10
  79. package/src/types/settings.ts +61 -35
  80. package/src/utils/deviceFeaturesUtils.ts +2 -2
  81. package/__tests__/check-firmware-release-protocol-v2.test.ts +0 -199
  82. package/__tests__/firmware-memory-host.test.ts +0 -112
  83. package/__tests__/firmware-update/firmware-prepared-host-digest.test.ts +0 -469
  84. package/dist/api/firmware/FirmwareMemoryHost.d.ts +0 -22
  85. package/dist/api/firmware/FirmwareMemoryHost.d.ts.map +0 -1
  86. package/dist/api/firmware/protocolV2Release.d.ts +0 -33
  87. package/dist/api/firmware/protocolV2Release.d.ts.map +0 -1
  88. package/src/api/firmware/FirmwareMemoryHost.ts +0 -142
  89. package/src/api/firmware/protocolV2Release.ts +0 -168
@@ -1,13 +1,11 @@
1
1
  import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { sha256 } from '@noble/hashes/sha256';
3
- import { bytesToHex } from '@noble/hashes/utils';
4
- import JSZip from 'jszip';
5
2
  import {
6
3
  DeviceRebootType,
7
4
  DeviceSessionPinType,
8
5
  DeviceSettingsPage,
9
6
  DeviceType,
10
7
  } from '@onekeyfe/hd-transport';
8
+ import { sha256 } from '@noble/hashes/sha256';
11
9
 
12
10
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
13
11
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -81,6 +79,7 @@ import {
81
79
  requestProtocolV2ProtocolInfo,
82
80
  supportsProtocolV2Message,
83
81
  } from '../src/protocols/protocol-v2/features';
82
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
84
83
  import {
85
84
  getProtocolV2WalletSession,
86
85
  refreshProtocolV2DeviceStatus,
@@ -103,12 +102,6 @@ import {
103
102
  } from '../src/utils';
104
103
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
105
104
  import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
106
- import {
107
- registerFirmwareUpdateHostBinding,
108
- unregisterFirmwareUpdateHostBinding,
109
- } from '../src/api/firmware/FirmwareHostBinding';
110
- import { prepareFirmwareUpdatePlan } from '../src/api/firmware/FirmwareUpdatePreparedPlan';
111
- import { digestFirmwareUpdateContract } from '../src/api/firmware/FirmwareUpdatePlan';
112
105
  import {
113
106
  getPassphraseState,
114
107
  getPassphraseStateWithRefreshDeviceInfo,
@@ -123,26 +116,6 @@ jest.mock('../src/data/config', () => ({
123
116
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
124
117
  }));
125
118
 
126
- const createProtocolV2OkppBinary = ({
127
- version = [1, 2, 3],
128
- payloadHashByte = 0x11,
129
- headerHashByte = 0x22,
130
- }: {
131
- version?: [number, number, number];
132
- payloadHashByte?: number;
133
- headerHashByte?: number;
134
- } = {}) => {
135
- const bytes = new Uint8Array(0x52a0);
136
- bytes.set([0x4f, 0x4b, 0x50, 0x50], 0);
137
- bytes.set([0x52, 0x45, 0x53, 0x43], 0x08);
138
- const view = new DataView(bytes.buffer);
139
- view.setUint32(0x0c, 0x52a0, true);
140
- view.setUint32(0x10, version[0] * 0x10000 + version[1] * 0x100 + version[2], true);
141
- bytes.fill(payloadHashByte, 0x200, 0x240);
142
- bytes.fill(headerHashByte, 0x240, 0x280);
143
- return bytes.buffer;
144
- };
145
-
146
119
  const createWalletSessionTypedCall = (
147
120
  implementation = jest.fn(),
148
121
  status: Partial<DeviceStatus> = { passphrase_enabled: true }
@@ -254,17 +227,6 @@ describe('DeviceUploadWallpaper', () => {
254
227
  });
255
228
 
256
229
  describe('UploadPortfolio', () => {
257
- const portfolioProtocolInfo = {
258
- version: 2,
259
- supported_messages: [60805, 61400],
260
- };
261
-
262
- const stubPortfolioDevice = <T extends Record<string, any>>(device: T) =>
263
- stubDevice({
264
- ...device,
265
- ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue(portfolioProtocolInfo),
266
- });
267
-
268
230
  test('writes and applies the portfolio while the device is locked without unlocking', async () => {
269
231
  const packageBytes = new Uint8Array([1, 2, 3]);
270
232
  const typedCall = jest
@@ -272,7 +234,7 @@ describe('UploadPortfolio', () => {
272
234
  .mockResolvedValueOnce({ message: { processed_byte: 3 } })
273
235
  .mockResolvedValueOnce({ message: { message: 'Portfolio updated' } });
274
236
  const unlockDevice = jest.fn().mockResolvedValue(undefined);
275
- const device = stubPortfolioDevice({
237
+ const device = stubDevice({
276
238
  features: { unlocked: false },
277
239
  commands: { typedCall },
278
240
  isProtocolV2: () => true,
@@ -317,7 +279,7 @@ describe('UploadPortfolio', () => {
317
279
  packageBytes,
318
280
  },
319
281
  });
320
- (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
282
+ (method as any).device = stubDevice({ commands: { typedCall } });
321
283
  method.postMessage = jest.fn();
322
284
 
323
285
  method.init();
@@ -365,7 +327,7 @@ describe('UploadPortfolio', () => {
365
327
  packageBytes: new Uint8Array([1]),
366
328
  },
367
329
  });
368
- (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
330
+ (method as any).device = stubDevice({ commands: { typedCall } });
369
331
 
370
332
  method.init();
371
333
 
@@ -388,7 +350,7 @@ describe('UploadPortfolio', () => {
388
350
  },
389
351
  });
390
352
  method.abortSignal = abortController.signal;
391
- (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
353
+ (method as any).device = stubDevice({ commands: { typedCall } });
392
354
 
393
355
  method.init();
394
356
 
@@ -398,31 +360,6 @@ describe('UploadPortfolio', () => {
398
360
  expect(typedCall).toHaveBeenCalledTimes(1);
399
361
  expect(typedCall).not.toHaveBeenCalledWith('PortfolioUpdate', 'Success', {});
400
362
  });
401
-
402
- test('rejects unsupported firmware before staging the package', async () => {
403
- const typedCall = jest.fn();
404
- const method = new UploadPortfolio({
405
- id: 1,
406
- payload: {
407
- method: 'uploadPortfolio',
408
- packageBytes: new Uint8Array([1]),
409
- },
410
- });
411
- (method as any).device = stubDevice({
412
- commands: { typedCall },
413
- ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
414
- version: 2,
415
- supported_messages: [60805],
416
- }),
417
- });
418
-
419
- method.init();
420
-
421
- await expect(method.run()).rejects.toMatchObject({
422
- errorCode: HardwareErrorCode.DeviceNotSupportMethod,
423
- });
424
- expect(typedCall).not.toHaveBeenCalled();
425
- });
426
363
  });
427
364
 
428
365
  const descriptor = {
@@ -2787,12 +2724,9 @@ describe('Protocol V2 feature adapter', () => {
2787
2724
  expect(checkedTypes).toEqual(['pro2', 'model_pro2']);
2788
2725
  });
2789
2726
 
2790
- test.each([
2791
- ['Pro2', DeviceType.PRO2],
2792
- ['Neo', DeviceType.NEO],
2793
- ] as const)('uses firmware-v1 as the %s remote firmware config field', (_name, deviceType) => {
2727
+ test('uses firmware-v1 as the Pro2 remote firmware config field', () => {
2794
2728
  const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2795
- hw: { Device_type: deviceType },
2729
+ hw: { Device_type: DeviceType.PRO2 },
2796
2730
  });
2797
2731
 
2798
2732
  expect(
@@ -4033,7 +3967,7 @@ describe('Protocol V2 firmware update targets', () => {
4033
3967
  });
4034
3968
  });
4035
3969
 
4036
- test('requires the external host to prepare manifest resources before bootloader entry', async () => {
3970
+ test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
4037
3971
  const method = new FirmwareUpdateV4({
4038
3972
  id: 1,
4039
3973
  payload: {
@@ -4053,21 +3987,55 @@ describe('Protocol V2 firmware update targets', () => {
4053
3987
  bootloaderMode: false,
4054
3988
  capabilities: [],
4055
3989
  },
4056
- getCurrentDeviceType: () => 'pro2',
4057
3990
  isBootloader: () => false,
4058
3991
  isRomloader: () => false,
4059
3992
  });
4060
3993
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4061
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
4062
- method.postTipMessage = jest.fn();
3994
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3995
+ bootloaderBinary: null,
3996
+ fwBinaryMap: [],
3997
+ installItems: [],
3998
+ });
4063
3999
 
4064
- await expect(method.run()).rejects.toMatchObject({
4065
- errorCode: HardwareErrorCode.RuntimeError,
4066
- params: expect.objectContaining({
4067
- firmwareUpdateCode: 'FirmwareArtifactsNotPrepared',
4068
- }),
4000
+ const order: string[] = [];
4001
+ (method as any).prepareProtocolV2BootResources = jest.fn().mockImplementation(() => {
4002
+ order.push('prepare-startup-resources');
4003
+ return Promise.resolve([]);
4069
4004
  });
4070
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4005
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4006
+ order.push('enter-bootloader');
4007
+ return Promise.resolve(true);
4008
+ });
4009
+ (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
4010
+ order.push('prepare-resources');
4011
+ return Promise.resolve([
4012
+ {
4013
+ name: 'images.okpkg',
4014
+ binary: new Uint8Array([1]).buffer,
4015
+ devicePath: 'vol0:/bundles/images/images.okpkg',
4016
+ },
4017
+ ]);
4018
+ });
4019
+ (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4020
+ order.push('execute-update');
4021
+ return Promise.resolve();
4022
+ });
4023
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4024
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4025
+ bootloaderVersion: '1.0.0',
4026
+ bleVersion: '1.0.0',
4027
+ firmwareVersion: '1.0.0',
4028
+ });
4029
+ method.postTipMessage = jest.fn();
4030
+
4031
+ await method.run();
4032
+
4033
+ expect(order).toEqual([
4034
+ 'prepare-startup-resources',
4035
+ 'enter-bootloader',
4036
+ 'prepare-resources',
4037
+ 'execute-update',
4038
+ ]);
4071
4039
  });
4072
4040
 
4073
4041
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
@@ -4236,6 +4204,9 @@ describe('Protocol V2 firmware update targets', () => {
4236
4204
  },
4237
4205
  });
4238
4206
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4207
+ const prepareResourceBundles = jest
4208
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4209
+ .mockResolvedValue(undefined);
4239
4210
  (method as any).protocolV2Reboot = jest.fn();
4240
4211
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
4241
4212
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -4248,6 +4219,7 @@ describe('Protocol V2 firmware update targets', () => {
4248
4219
 
4249
4220
  await method.run();
4250
4221
 
4222
+ expect(prepareResourceBundles).not.toHaveBeenCalled();
4251
4223
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4252
4224
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4253
4225
  bootloaderBinary: null,
@@ -5586,33 +5558,21 @@ describe('Protocol V2 firmware update targets', () => {
5586
5558
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
5587
5559
  .fn()
5588
5560
  .mockResolvedValue(undefined);
5589
- (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
5590
5561
 
5591
- await (method as any).executeProtocolV2SourceUpdate({
5592
- resourceSources: [
5562
+ await (method as any).executeProtocolV2Update({
5563
+ resourceBundles: [
5593
5564
  {
5594
5565
  name: 'images.okpkg',
5595
- source: {
5596
- size: 2,
5597
- readAt: jest.fn(),
5598
- close: jest.fn(),
5599
- },
5566
+ binary: new Uint8Array([1, 2]).buffer,
5600
5567
  devicePath: 'vol0:/bundles/images/images.okpkg',
5601
- version: [1, 2, 3],
5602
- payloadHash: '11'.repeat(64),
5603
- headerHash: '22'.repeat(64),
5604
5568
  },
5605
5569
  ],
5606
- installSources: [
5570
+ bootloaderBinary: null,
5571
+ fwBinaryMap: [
5607
5572
  {
5608
5573
  fileName: 'application_p1.bin',
5609
- source: {
5610
- size: 1,
5611
- readAt: jest.fn(),
5612
- close: jest.fn(),
5613
- },
5574
+ binary: new Uint8Array([3]).buffer,
5614
5575
  targetId: 4,
5615
- kind: 'firmware',
5616
5576
  },
5617
5577
  ],
5618
5578
  });
@@ -5629,32 +5589,6 @@ describe('Protocol V2 firmware update targets', () => {
5629
5589
  2,
5630
5590
  expect.objectContaining({ processedSize: 0, totalSize: 1 })
5631
5591
  );
5632
- expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5633
- expect.objectContaining({
5634
- version: [1, 2, 3],
5635
- payloadHash: '11'.repeat(64),
5636
- headerHash: '22'.repeat(64),
5637
- })
5638
- );
5639
- });
5640
-
5641
- test('installs and verifies bootloader before syncing manifest resources', () => {
5642
- const method = new FirmwareUpdateV4({
5643
- id: 1,
5644
- payload: { method: 'firmwareUpdateV4' },
5645
- });
5646
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5647
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5648
- resourceSources: [{ kind: 'resource' }],
5649
- });
5650
-
5651
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5652
- 'bootloader-install',
5653
- 'bootloader-verify',
5654
- 'resource-sync',
5655
- 'component-install',
5656
- 'final-verify',
5657
- ]);
5658
5592
  });
5659
5593
 
5660
5594
  test('does not request installation when the staged file size does not match', async () => {
@@ -5799,14 +5733,6 @@ describe('Protocol V2 firmware update targets', () => {
5799
5733
  binary: binaries.get(url) ?? new Uint8Array([0]).buffer,
5800
5734
  })
5801
5735
  );
5802
- const componentIntegrity = (url: string) => {
5803
- const binary = binaries.get(url);
5804
- if (!binary) throw new Error(`Missing test firmware binary: ${url}`);
5805
- return {
5806
- expectedSize: binary.byteLength,
5807
- fingerprint: bytesToHex(sha256(new Uint8Array(binary))),
5808
- };
5809
- };
5810
5736
  const getFirmwareLatestReleaseSpy = jest
5811
5737
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5812
5738
  .mockReturnValue({
@@ -5831,43 +5757,23 @@ describe('Protocol V2 firmware update targets', () => {
5831
5757
  bootloader: {
5832
5758
  target: 'BOOTLOADER',
5833
5759
  url: 'https://example.com/bootloader.pp.bin',
5834
- ...componentIntegrity('https://example.com/bootloader.pp.bin'),
5835
5760
  },
5836
5761
  applicationP1: {
5837
5762
  target: 'APPLICATION_P1',
5838
5763
  url: 'https://example.com/applicationP1.pp.bin',
5839
- ...componentIntegrity('https://example.com/applicationP1.pp.bin'),
5840
5764
  },
5841
5765
  applicationP2: {
5842
5766
  target: 'APPLICATION_P2',
5843
5767
  url: 'https://example.com/applicationP2.pp.bin',
5844
- ...componentIntegrity('https://example.com/applicationP2.pp.bin'),
5845
5768
  },
5846
5769
  coprocessor: {
5847
5770
  target: 'COPROCESSOR',
5848
5771
  url: 'https://example.com/coprocessor.pp.bin',
5849
- ...componentIntegrity('https://example.com/coprocessor.pp.bin'),
5850
- },
5851
- se01: {
5852
- target: 'SE01',
5853
- url: 'https://example.com/se01.pp.bin',
5854
- ...componentIntegrity('https://example.com/se01.pp.bin'),
5855
- },
5856
- se02: {
5857
- target: 'SE02',
5858
- url: 'https://example.com/se02.pp.bin',
5859
- ...componentIntegrity('https://example.com/se02.pp.bin'),
5860
- },
5861
- se03: {
5862
- target: 'SE03',
5863
- url: 'https://example.com/se03.pp.bin',
5864
- ...componentIntegrity('https://example.com/se03.pp.bin'),
5865
- },
5866
- se04: {
5867
- target: 'SE04',
5868
- url: 'https://example.com/se04.pp.bin',
5869
- ...componentIntegrity('https://example.com/se04.pp.bin'),
5870
5772
  },
5773
+ se01: { target: 'SE01', url: 'https://example.com/se01.pp.bin' },
5774
+ se02: { target: 'SE02', url: 'https://example.com/se02.pp.bin' },
5775
+ se03: { target: 'SE03', url: 'https://example.com/se03.pp.bin' },
5776
+ se04: { target: 'SE04', url: 'https://example.com/se04.pp.bin' },
5871
5777
  },
5872
5778
  fingerprint: '',
5873
5779
  changelog: {
@@ -5908,84 +5814,6 @@ describe('Protocol V2 firmware update targets', () => {
5908
5814
  getFirmwareLatestReleaseSpy.mockRestore();
5909
5815
  });
5910
5816
 
5911
- test('downloads only firmware targets that are missing from an explicit mixed payload', async () => {
5912
- const explicitApplicationBinary = new Uint8Array([1]).buffer;
5913
- const remoteCoprocessorBinary = new Uint8Array([2]).buffer;
5914
- const method = new FirmwareUpdateV4({
5915
- id: 1,
5916
- payload: {
5917
- method: 'firmwareUpdateV4',
5918
- platform: 'web',
5919
- targetsToUpdate: ['app_v1', 'coprocessor'],
5920
- },
5921
- });
5922
- method.init();
5923
-
5924
- const getSysResourceBinarySpy = jest
5925
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5926
- .mockResolvedValue({ binary: remoteCoprocessorBinary });
5927
- const getFirmwareLatestReleaseSpy = jest
5928
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5929
- .mockReturnValue({
5930
- required: false,
5931
- version: [1, 0, 0],
5932
- url: 'https://example.com/applicationP1.pp.bin',
5933
- installOrder: ['applicationP1', 'coprocessor'],
5934
- components: {
5935
- applicationP1: {
5936
- target: 'APPLICATION_P1',
5937
- url: 'https://example.com/applicationP1.pp.bin',
5938
- expectedSize: explicitApplicationBinary.byteLength,
5939
- fingerprint: bytesToHex(sha256(new Uint8Array(explicitApplicationBinary))),
5940
- },
5941
- coprocessor: {
5942
- target: 'COPROCESSOR',
5943
- url: 'https://example.com/coprocessor.pp.bin',
5944
- expectedSize: remoteCoprocessorBinary.byteLength,
5945
- fingerprint: bytesToHex(sha256(new Uint8Array(remoteCoprocessorBinary))),
5946
- },
5947
- },
5948
- fingerprint: '',
5949
- changelog: {
5950
- 'zh-CN': '',
5951
- 'en-US': '',
5952
- },
5953
- });
5954
-
5955
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries(
5956
- 'universal',
5957
- { deviceType: 'pro2', firmwareVersion: '0.0.0' },
5958
- [
5959
- {
5960
- fileName: 'application_p1.bin',
5961
- binary: explicitApplicationBinary,
5962
- targetId: 4,
5963
- kind: 'firmware',
5964
- },
5965
- ]
5966
- );
5967
-
5968
- expect(getSysResourceBinarySpy).toHaveBeenCalledTimes(1);
5969
- expect(getSysResourceBinarySpy).toHaveBeenCalledWith('https://example.com/coprocessor.pp.bin');
5970
- expect(remoteBinaries.installItems).toEqual([
5971
- {
5972
- fileName: 'application_p1.bin',
5973
- binary: explicitApplicationBinary,
5974
- targetId: 4,
5975
- kind: 'firmware',
5976
- },
5977
- {
5978
- fileName: 'coprocessor.bin',
5979
- binary: remoteCoprocessorBinary,
5980
- targetId: 6,
5981
- kind: 'firmware',
5982
- },
5983
- ]);
5984
-
5985
- getSysResourceBinarySpy.mockRestore();
5986
- getFirmwareLatestReleaseSpy.mockRestore();
5987
- });
5988
-
5989
5817
  test('ignores an unsupported remote ROMLOADER component when installing selected targets', async () => {
5990
5818
  const method = new FirmwareUpdateV4({
5991
5819
  id: 1,
@@ -6150,753 +5978,211 @@ describe('Protocol V2 firmware update targets', () => {
6150
5978
  });
6151
5979
  });
6152
5980
 
6153
- test('maps a prepared resource archive manifest to artifact-backed resource sources', async () => {
5981
+ test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
6154
5982
  const method = new FirmwareUpdateV4({
6155
5983
  id: 1,
6156
5984
  payload: {
6157
5985
  method: 'firmwareUpdateV4',
6158
5986
  },
6159
5987
  });
6160
- const imagesBinary = createProtocolV2OkppBinary();
6161
- const bootBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x33 });
6162
- const imagesSha256 = bytesToHex(sha256(new Uint8Array(imagesBinary)));
6163
- const bootSha256 = bytesToHex(sha256(new Uint8Array(bootBinary)));
6164
- const manifest = {
6165
- schema: 1,
6166
- artifact_name: 'pro2-resource',
6167
- release_name: 'test-release',
6168
- variant: 'resource',
6169
- commit: 'abc123',
6170
- short_sha: 'abc123',
6171
- timestamp_utc: '2026-08-09T00:00:00Z',
6172
- core_version: '1.0.0',
6173
- key_set: 'production',
6174
- device_root: 'vol0:',
6175
- restore_mode: 'bootloader_update',
6176
- trees: [{ path: 'bundles', device: 'pro2' }],
6177
- files: [
6178
- {
6179
- archive_path: 'bundles/images/images.okpkg',
6180
- original_name: 'images.okpkg',
6181
- device_path: 'vol0:/bundles/images/images.okpkg',
6182
- size: imagesBinary.byteLength,
6183
- sha256: imagesSha256,
6184
- signed: true,
6185
- sig_algo: 'ed25519',
6186
- payload_version: '1.2.3',
6187
- },
5988
+ const openPreparedSource = jest.fn();
5989
+ (method as any).params = {
5990
+ targetsToUpdate: ['resource'],
5991
+ resourceBundleArtifacts: [
6188
5992
  {
6189
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6190
- original_name: 'boot_resource.okpkg',
6191
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6192
- size: bootBinary.byteLength,
6193
- sha256: bootSha256,
6194
- signed: true,
6195
- sig_algo: 'ed25519',
6196
- payload_version: '1.2.3',
5993
+ name: 'images',
5994
+ artifact: {
5995
+ artifactRef: `fw:${'a'.repeat(64)}`,
5996
+ size: 1,
5997
+ sha256: 'a'.repeat(64),
5998
+ },
6197
5999
  },
6198
6000
  ],
6199
6001
  };
6200
- const manifestBinary = new TextEncoder().encode(JSON.stringify(manifest)).buffer;
6201
- const zip = new JSZip();
6202
- zip.file('manifest.json', manifestBinary);
6203
- zip.file('bundles/images/images.okpkg', imagesBinary);
6204
- zip.file('loaders/bootloader/boot_resource.okpkg', bootBinary);
6205
- const archiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6206
- const preparedEntries = [
6207
- {
6208
- entryName: 'manifest.json',
6209
- artifact: {
6210
- artifactRef: 'manifest',
6211
- size: manifestBinary.byteLength,
6212
- sha256: bytesToHex(sha256(new Uint8Array(manifestBinary))),
6213
- },
6214
- },
6215
- {
6216
- entryName: 'bundles/images/images.okpkg',
6217
- artifact: {
6218
- artifactRef: 'images',
6219
- size: imagesBinary.byteLength,
6220
- sha256: imagesSha256,
6221
- },
6222
- },
6223
- {
6224
- entryName: 'loaders/bootloader/boot_resource.okpkg',
6225
- artifact: {
6226
- artifactRef: 'boot',
6227
- size: bootBinary.byteLength,
6228
- sha256: bootSha256,
6229
- },
6230
- },
6231
- ];
6232
- const artifacts = new Map([['archive', archiveBinary]]);
6233
- (method as any).params = {
6234
- targetsToUpdate: ['resource'],
6235
- preparedPlan: {
6236
- artifacts: [
6002
+ (method as any).openProtocolV2PreparedSource = openPreparedSource;
6003
+ const getFirmwareLatestReleaseSpy = jest
6004
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6005
+ .mockReturnValue({
6006
+ required: false,
6007
+ version: [1, 0, 0],
6008
+ url: '',
6009
+ resourceBundles: [
6237
6010
  {
6238
- artifactId: 'resource:archive',
6239
- role: 'resourceBundle',
6240
- target: 'resource',
6241
- container: 'zip',
6242
- artifact: {
6243
- artifactRef: 'archive',
6244
- size: archiveBinary.byteLength,
6245
- sha256: bytesToHex(sha256(new Uint8Array(archiveBinary))),
6246
- },
6247
- materializedEntries: preparedEntries,
6011
+ name: 'images',
6012
+ url: 'https://example.com/images.okpkg',
6013
+ devicePath: 'vol0:/resource/../images.okpkg',
6248
6014
  },
6249
6015
  ],
6250
- },
6251
- };
6252
- (method as any).openProtocolV2PreparedSource = jest.fn(async artifact => {
6253
- const binary = artifacts.get(artifact.artifactRef);
6254
- if (!binary) throw new Error(`Unknown artifact: ${artifact.artifactRef}`);
6255
- return openFirmwareByteSource({ binary });
6256
- });
6257
-
6258
- const sources = await (method as any).prepareProtocolV2ResourceArchiveSources();
6016
+ fingerprint: '',
6017
+ changelog: {
6018
+ 'zh-CN': '',
6019
+ 'en-US': '',
6020
+ },
6021
+ });
6259
6022
 
6260
- expect(sources).toHaveLength(2);
6261
- expect(sources).toEqual([
6262
- expect.objectContaining({
6263
- name: 'images.okpkg',
6264
- devicePath: 'vol0:/bundles/images/images.okpkg',
6265
- version: [1, 2, 3],
6266
- }),
6267
- expect.objectContaining({
6268
- name: 'boot_resource.okpkg',
6269
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6270
- version: [1, 2, 3],
6271
- }),
6272
- ]);
6273
- await Promise.all(sources.map(source => source.source.close()));
6023
+ await expect(
6024
+ (method as any).prepareProtocolV2ResourceSources('universal', {
6025
+ deviceType: 'pro2',
6026
+ })
6027
+ ).rejects.toThrow('resourceBundles[].devicePath');
6028
+ expect(openPreparedSource).not.toHaveBeenCalled();
6274
6029
 
6275
- preparedEntries[1].artifact.sha256 = 'f'.repeat(64);
6276
- await expect((method as any).prepareProtocolV2ResourceArchiveSources()).rejects.toThrow(
6277
- 'entries do not match the approved archive'
6278
- );
6030
+ getFirmwareLatestReleaseSpy.mockRestore();
6279
6031
  });
6280
6032
 
6281
- test('binds a prepared resource archive to the selected host generation', () => {
6282
- const planWithoutDigest = {
6283
- schemaVersion: 2 as const,
6284
- executor: 'v4' as const,
6285
- deviceIdentity: 'PRO2-SERIAL',
6286
- deviceModel: 'pro2',
6287
- firmwareType: EFirmwareType.Universal,
6288
- platform: 'web' as const,
6289
- targetsToUpdate: ['resource' as const],
6290
- artifacts: [
6291
- {
6292
- artifactId: 'resource:archive',
6293
- role: 'resourceBundle' as const,
6294
- target: 'resource' as const,
6295
- url: 'https://example.com/resource.zip',
6296
- container: 'zip' as const,
6297
- logicalName: 'protocol-v2-resource-archive',
6298
- },
6299
- ],
6300
- };
6301
- const plan = {
6302
- ...planWithoutDigest,
6303
- planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6304
- };
6305
- const preparedPlan = prepareFirmwareUpdatePlan({
6306
- plan,
6307
- leaseRef: 'resource-archive-test',
6308
- artifacts: [
6309
- {
6310
- artifactId: 'resource:archive',
6311
- artifact: {
6312
- artifactRef: 'resource-archive',
6313
- size: 3,
6314
- sha256: 'a'.repeat(64),
6315
- },
6316
- materializedEntries: [
6317
- {
6318
- entryName: 'manifest.json',
6319
- artifact: {
6320
- artifactRef: 'resource-manifest',
6321
- size: 2,
6322
- sha256: 'b'.repeat(64),
6323
- },
6324
- },
6325
- ],
6326
- },
6327
- ],
6328
- });
6329
- const mismatchedHostBindingGeneration = registerFirmwareUpdateHostBinding({
6330
- preparedPlanDigest: 'c'.repeat(64),
6331
- artifactReader: {
6332
- open: jest.fn(),
6333
- read: jest.fn(),
6334
- close: jest.fn(),
6033
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
6034
+ const method = new FirmwareUpdateV4({
6035
+ id: 1,
6036
+ payload: {
6037
+ method: 'firmwareUpdateV4',
6038
+ platform: 'web',
6335
6039
  },
6336
6040
  });
6337
- try {
6338
- const mismatchedMethod = new FirmwareUpdateV4({
6339
- id: 1,
6340
- payload: {
6341
- method: 'firmwareUpdateV4',
6342
- platform: 'web',
6343
- targetsToUpdate: ['resource'],
6344
- preparedPlan,
6345
- hostBindingGeneration: mismatchedHostBindingGeneration,
6346
- },
6347
- });
6041
+ method.init();
6042
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6043
+ (method as any).device = stubDevice({
6044
+ originalDescriptor: { protocolType: 'V2' },
6045
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6046
+ });
6047
+ forceReloadDataSpy.mockRejectedValue(
6048
+ ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
6049
+ );
6050
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6051
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
6052
+ method.postTipMessage = jest.fn();
6348
6053
 
6349
- expect(() => mismatchedMethod.init()).toThrow('does not match the prepared plan');
6350
- } finally {
6351
- unregisterFirmwareUpdateHostBinding(mismatchedHostBindingGeneration);
6352
- }
6353
- const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6354
- preparedPlanDigest: preparedPlan.preparedPlanDigest,
6355
- artifactReader: {
6356
- open: jest.fn(),
6357
- read: jest.fn(),
6358
- close: jest.fn(),
6359
- },
6054
+ await expect(method.run()).rejects.toMatchObject({
6055
+ errorCode: HardwareErrorCode.NetworkError,
6056
+ message: 'latest config unavailable',
6360
6057
  });
6361
- try {
6362
- const method = new FirmwareUpdateV4({
6363
- id: 1,
6364
- payload: {
6365
- method: 'firmwareUpdateV4',
6366
- platform: 'web',
6367
- targetsToUpdate: ['resource'],
6368
- preparedPlan,
6369
- hostBindingGeneration,
6370
- },
6371
- });
6372
6058
 
6373
- expect(() => method.init()).not.toThrow();
6374
- } finally {
6375
- unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6376
- }
6059
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6060
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
6377
6061
  });
6378
6062
 
6379
- test('executes a prepared component plan without duplicated component inputs', async () => {
6380
- const componentBinary = new Uint8Array([1, 2, 3]).buffer;
6381
- const componentArtifact = {
6382
- artifactRef: 'prepared-bootloader',
6383
- size: componentBinary.byteLength,
6384
- sha256: 'a'.repeat(64),
6385
- };
6386
- const planWithoutDigest = {
6387
- schemaVersion: 2 as const,
6388
- executor: 'v4' as const,
6389
- deviceIdentity: 'PRO2-SERIAL',
6390
- deviceModel: 'pro2',
6391
- firmwareType: EFirmwareType.Universal,
6392
- platform: 'web' as const,
6393
- targetsToUpdate: ['boot' as const],
6394
- artifacts: [
6395
- {
6396
- artifactId: 'component:boot',
6397
- role: 'component' as const,
6398
- target: 'boot' as const,
6399
- url: 'https://example.com/bootloader.bin',
6400
- container: 'raw' as const,
6401
- logicalName: 'bootloader',
6402
- targetVersion: '1.2.3',
6403
- },
6404
- ],
6405
- };
6406
- const preparedPlan = prepareFirmwareUpdatePlan({
6407
- plan: {
6408
- ...planWithoutDigest,
6409
- planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6410
- },
6411
- leaseRef: 'prepared-component-test',
6412
- artifacts: [{ artifactId: 'component:boot', artifact: componentArtifact }],
6413
- });
6414
- const artifactReader = {
6415
- open: jest.fn(() =>
6416
- Promise.resolve({
6417
- readerId: 'prepared-component-reader',
6418
- size: componentBinary.byteLength,
6419
- })
6420
- ),
6421
- read: jest.fn(({ offset, length }: { offset: number; length: number }) => {
6422
- const data = componentBinary.slice(offset, offset + length);
6423
- return Promise.resolve({
6424
- data,
6425
- bytesRead: data.byteLength,
6426
- eof: offset + length === componentBinary.byteLength,
6427
- });
6428
- }),
6429
- close: jest.fn(() => Promise.resolve()),
6430
- };
6431
- const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6432
- preparedPlanDigest: preparedPlan.preparedPlanDigest,
6433
- artifactReader,
6063
+ test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
6064
+ const method = new FirmwareUpdateV4({
6065
+ id: 1,
6066
+ payload: {
6067
+ method: 'firmwareUpdateV4',
6068
+ platform: 'web',
6069
+ coprocessorBinary: new Uint8Array([1]).buffer,
6070
+ },
6434
6071
  });
6435
- const releaseSpy = jest
6436
- .spyOn(DataManager, 'getFirmwareLatestRelease')
6437
- .mockReturnValue(undefined);
6438
-
6439
- try {
6440
- const method = new FirmwareUpdateV4({
6441
- id: 1,
6442
- payload: {
6443
- method: 'firmwareUpdateV4',
6444
- platform: 'web',
6445
- preparedPlan,
6446
- hostBindingGeneration,
6447
- },
6448
- });
6072
+ method.init();
6073
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6449
6074
 
6450
- expect(() => method.init()).not.toThrow();
6451
- expect((method as any).params.targetsToUpdate).toEqual(['boot']);
6452
- expect((method as any).params.expectedTargetVersions).toEqual({ boot: '1.2.3' });
6453
- expect((method as any).params.componentArtifacts).toBeUndefined();
6454
- (method as any).postTipMessage = jest.fn();
6455
- (method as any).prepareProtocolV2ResourceSources = jest.fn().mockResolvedValue([]);
6456
- (method as any).executeProtocolV2SourceUpdate = jest
6457
- .fn()
6458
- .mockResolvedValue('prepared-result');
6075
+ (method as any).device = stubDevice({
6076
+ originalDescriptor: { protocolType: 'V2' },
6077
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6078
+ });
6079
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6080
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
6081
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
6082
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
6083
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
6084
+ bootloaderVersion: '1.0.0',
6085
+ bleVersion: '0.0.0',
6086
+ firmwareVersion: '1.0.0',
6087
+ });
6088
+ method.postTipMessage = jest.fn();
6459
6089
 
6460
- await expect(
6461
- (method as any).runProtocolV2PreparedArtifacts({} as Features, EFirmwareType.Universal)
6462
- ).resolves.toBe('prepared-result');
6463
- expect(releaseSpy).not.toHaveBeenCalled();
6464
- expect(artifactReader.open).toHaveBeenCalledWith({ artifactRef: 'prepared-bootloader' });
6465
- expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith({
6466
- installSources: [
6467
- expect.objectContaining({
6468
- fileName: 'bootloader.bin',
6469
- kind: 'bootloader',
6470
- }),
6471
- ],
6472
- resourceSources: [],
6473
- });
6474
- } finally {
6475
- releaseSpy.mockRestore();
6476
- unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6477
- }
6478
- });
6090
+ await method.run();
6479
6091
 
6480
- test('executes a resource-only prepared plan without a live firmware release', async () => {
6481
- const planWithoutDigest = {
6482
- schemaVersion: 2 as const,
6483
- executor: 'v4' as const,
6484
- deviceIdentity: 'PRO2-SERIAL',
6485
- deviceModel: 'pro2',
6486
- firmwareType: EFirmwareType.Universal,
6487
- platform: 'web' as const,
6488
- targetsToUpdate: ['resource' as const],
6489
- artifacts: [
6490
- {
6491
- artifactId: 'resource:archive',
6492
- role: 'resourceBundle' as const,
6493
- target: 'resource' as const,
6494
- url: 'https://example.com/resource.zip',
6495
- container: 'zip' as const,
6496
- logicalName: 'protocol-v2-resource-archive',
6497
- },
6498
- ],
6499
- };
6500
- const preparedPlan = prepareFirmwareUpdatePlan({
6501
- plan: {
6502
- ...planWithoutDigest,
6503
- planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6504
- },
6505
- leaseRef: 'prepared-resource-only-test',
6506
- artifacts: [
6507
- {
6508
- artifactId: 'resource:archive',
6509
- artifact: {
6510
- artifactRef: 'prepared-resource-archive',
6511
- size: 3,
6512
- sha256: 'a'.repeat(64),
6513
- },
6514
- materializedEntries: [
6515
- {
6516
- entryName: 'manifest.json',
6517
- artifact: {
6518
- artifactRef: 'prepared-resource-manifest',
6519
- size: 2,
6520
- sha256: 'b'.repeat(64),
6521
- },
6522
- },
6523
- ],
6524
- },
6525
- ],
6526
- });
6527
- const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6528
- preparedPlanDigest: preparedPlan.preparedPlanDigest,
6529
- artifactReader: {
6530
- open: jest.fn(),
6531
- read: jest.fn(),
6532
- close: jest.fn(),
6533
- },
6092
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6093
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
6094
+ bootloaderBinary: null,
6095
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
6534
6096
  });
6535
- const releaseSpy = jest
6536
- .spyOn(DataManager, 'getFirmwareLatestRelease')
6537
- .mockReturnValue(undefined);
6097
+ });
6538
6098
 
6539
- try {
6099
+ test.each(['resource', 'boot_resources'] as const)(
6100
+ 'treats manual resource files as authoritative payload for %s',
6101
+ async target => {
6102
+ const resourceBundle = new Uint8Array([1, 2, 3]).buffer;
6540
6103
  const method = new FirmwareUpdateV4({
6541
6104
  id: 1,
6542
6105
  payload: {
6543
6106
  method: 'firmwareUpdateV4',
6544
6107
  platform: 'web',
6545
- preparedPlan,
6546
- hostBindingGeneration,
6108
+ targetsToUpdate: [target],
6109
+ resourceFiles: [
6110
+ {
6111
+ binary: resourceBundle,
6112
+ devicePath: ' VOL0:/resource/images/images.okpkg ',
6113
+ },
6114
+ ],
6547
6115
  },
6548
6116
  });
6117
+ method.init();
6118
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6119
+ const bootResourcesSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6549
6120
 
6550
- expect(() => method.init()).not.toThrow();
6551
- (method as any).postTipMessage = jest.fn();
6552
- const resourceSources = [{ kind: 'resource' }];
6553
- (method as any).prepareProtocolV2ResourceSources = jest
6554
- .fn()
6555
- .mockResolvedValue(resourceSources);
6556
- (method as any).executeProtocolV2SourceUpdate = jest
6557
- .fn()
6558
- .mockResolvedValue('resource-result');
6559
-
6560
- await expect(
6561
- (method as any).runProtocolV2PreparedArtifacts({} as Features, EFirmwareType.Universal)
6562
- ).resolves.toBe('resource-result');
6563
- expect(releaseSpy).not.toHaveBeenCalled();
6564
- expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith({
6565
- installSources: [],
6566
- resourceSources,
6121
+ (method as any).device = stubDevice({
6122
+ originalDescriptor: { protocolType: 'V2' },
6123
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6567
6124
  });
6568
- } finally {
6569
- releaseSpy.mockRestore();
6570
- unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6571
- }
6572
- });
6573
-
6574
- test('converts a local resource ZIP into a local PreparedPlan without matching a remote release', async () => {
6575
- const imagesBinary = new Uint8Array([1, 2, 3]).buffer;
6576
- const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6577
- const manifest = {
6578
- schema: 1,
6579
- artifact_name: 'pro2-resource',
6580
- release_name: 'local-development',
6581
- variant: 'resource',
6582
- commit: 'local',
6583
- short_sha: 'local',
6584
- timestamp_utc: '2026-08-09T00:00:00Z',
6585
- core_version: '1.0.0',
6586
- key_set: 'development',
6587
- device_root: 'vol0:',
6588
- restore_mode: 'bootloader_update',
6589
- trees: [{ path: 'bundles', device: 'pro2' }],
6590
- files: [
6591
- {
6592
- archive_path: 'bundles/images/images.okpkg',
6593
- original_name: 'images.okpkg',
6594
- device_path: 'vol0:/bundles/images/images.okpkg',
6595
- size: imagesBinary.byteLength,
6596
- sha256: bytesToHex(sha256(new Uint8Array(imagesBinary))),
6597
- signed: true,
6598
- sig_algo: 'ed25519',
6599
- payload_version: '1.0.0',
6600
- },
6601
- {
6602
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6603
- original_name: 'boot_resource.okpkg',
6604
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6605
- size: bootResourceBinary.byteLength,
6606
- sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6607
- signed: true,
6608
- sig_algo: 'ed25519',
6609
- payload_version: '1.0.0',
6610
- },
6611
- ],
6612
- };
6613
- const zip = new JSZip();
6614
- zip.file('manifest.json', JSON.stringify(manifest));
6615
- zip.file('bundles/images/images.okpkg', imagesBinary);
6616
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
6617
- const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6618
- const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
6619
- const method = new FirmwareUpdateV4({
6620
- id: 1,
6621
- payload: {
6622
- method: 'firmwareUpdateV4',
6623
- platform: 'web',
6624
- targetsToUpdate: ['app_v1'],
6625
- applicationP1Binary,
6626
- resourceArchiveBinary,
6627
- },
6628
- });
6629
- method.init();
6630
- expect((method as any).params.targetsToUpdate).toEqual(['app_v1', 'resource']);
6631
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6632
- (method as any).postTipMessage = jest.fn();
6633
- (method as any).device = stubDevice({
6634
- originalDescriptor: { protocolType: 'V2' },
6635
- features: {
6636
- deviceType: 'pro2',
6637
- serialNo: 'pro2-device-id',
6125
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6126
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
6127
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
6128
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
6129
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
6130
+ bootloaderVersion: '1.0.0',
6131
+ bleVersion: '0.0.0',
6638
6132
  firmwareVersion: '1.0.0',
6639
- capabilities: [],
6640
- },
6641
- getCurrentDeviceType: () => 'pro2',
6642
- });
6643
- (method as any).protocolV2ExpectedSerialNumber = 'pro2-device-id';
6644
- (method as any).executeProtocolV2SourceUpdate = jest
6645
- .fn()
6646
- .mockResolvedValue('local-resource-result');
6647
- const releaseSpy = jest.spyOn(DataManager, 'getFirmwareLatestRelease');
6648
- const reloadSpy = jest.spyOn(DataManager, 'forceReloadData');
6649
-
6650
- try {
6651
- await expect(method.run()).resolves.toBe('local-resource-result');
6652
- expect(releaseSpy).not.toHaveBeenCalled();
6653
- expect(reloadSpy).not.toHaveBeenCalled();
6654
- expect((method as any).params.preparedPlan).toMatchObject({
6655
- networkPolicy: 'forbid',
6656
- executor: 'v4',
6657
- targetsToUpdate: ['app_v1', 'resource'],
6658
6133
  });
6659
- expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith(
6134
+ method.postTipMessage = jest.fn();
6135
+
6136
+ await method.run();
6137
+
6138
+ expect(forceReloadDataSpy).not.toHaveBeenCalled();
6139
+ expect(bootResourcesSpy).not.toHaveBeenCalled();
6140
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6141
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith(
6660
6142
  expect.objectContaining({
6661
- installSources: [
6662
- expect.objectContaining({
6663
- fileName: 'application_p1.bin',
6664
- targetId: 4,
6665
- }),
6666
- ],
6667
- resourceSources: [
6668
- expect.objectContaining({
6143
+ resourceBundles: [
6144
+ {
6669
6145
  name: 'images.okpkg',
6670
- devicePath: 'vol0:/bundles/images/images.okpkg',
6671
- }),
6672
- expect.objectContaining({
6673
- name: 'boot_resource.okpkg',
6674
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6675
- }),
6146
+ binary: resourceBundle,
6147
+ devicePath: 'vol0:/resource/images/images.okpkg',
6148
+ },
6676
6149
  ],
6677
6150
  })
6678
6151
  );
6679
- } finally {
6680
- releaseSpy.mockRestore();
6681
- reloadSpy.mockRestore();
6682
6152
  }
6683
- });
6153
+ );
6684
6154
 
6685
- test('rejects a local resource ZIP whose file hash does not match its manifest', async () => {
6686
- const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
6687
- const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6688
- const zip = new JSZip();
6689
- zip.file(
6690
- 'manifest.json',
6691
- JSON.stringify({
6692
- schema: 1,
6693
- artifact_name: 'pro2-resource',
6694
- release_name: 'local-development',
6695
- variant: 'resource',
6696
- commit: 'local',
6697
- short_sha: 'local',
6698
- timestamp_utc: '2026-08-09T00:00:00Z',
6699
- core_version: '1.0.0',
6700
- key_set: 'development',
6701
- device_root: 'vol0:',
6702
- restore_mode: 'bootloader_update',
6703
- trees: [{ path: 'bundles', device: 'pro2' }],
6704
- files: [
6705
- {
6706
- archive_path: 'bundles/images/images.okpkg',
6707
- original_name: 'images.okpkg',
6708
- device_path: 'vol0:/bundles/images/images.okpkg',
6709
- size: resourceBinary.byteLength,
6710
- sha256: '0'.repeat(64),
6711
- signed: true,
6712
- sig_algo: 'ed25519',
6713
- payload_version: '1.0.0',
6714
- },
6715
- {
6716
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6717
- original_name: 'boot_resource.okpkg',
6718
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6719
- size: bootResourceBinary.byteLength,
6720
- sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6721
- signed: true,
6722
- sig_algo: 'ed25519',
6723
- payload_version: '1.0.0',
6724
- },
6725
- ],
6726
- })
6727
- );
6728
- zip.file('bundles/images/images.okpkg', resourceBinary);
6729
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
6155
+ test('rejects manual RESC bundle paths before bootloader entry', async () => {
6730
6156
  const method = new FirmwareUpdateV4({
6731
6157
  id: 1,
6732
6158
  payload: {
6733
6159
  method: 'firmwareUpdateV4',
6734
6160
  platform: 'web',
6735
- targetsToUpdate: ['resource'],
6736
- },
6737
- });
6738
- method.init();
6739
-
6740
- await expect(
6741
- (method as any).prepareProtocolV2LocalResourceArchive(
6742
- await zip.generateAsync({ type: 'arraybuffer' })
6743
- )
6744
- ).rejects.toThrow(
6745
- 'Protocol V2 local resource file does not match manifest: bundles/images/images.okpkg'
6746
- );
6747
- });
6748
-
6749
- test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6750
- const extractEntry = jest.fn();
6751
- const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
6752
- files: {
6753
- 'manifest.json': {
6754
- name: 'manifest.json',
6755
- dir: false,
6756
- _data: {
6757
- compressedSize: 1,
6758
- uncompressedSize: 2 * 1024 * 1024,
6161
+ resourceFiles: [
6162
+ {
6163
+ binary: new Uint8Array([1, 2, 3]).buffer,
6164
+ devicePath: 'vol2:/resource/images/images.okpkg',
6759
6165
  },
6760
- async: extractEntry,
6761
- },
6762
- },
6763
- } as unknown as JSZip);
6764
- const method = new FirmwareUpdateV4({
6765
- id: 1,
6766
- payload: {
6767
- method: 'firmwareUpdateV4',
6768
- platform: 'web',
6769
- targetsToUpdate: ['resource'],
6770
- },
6771
- });
6772
- method.init();
6773
-
6774
- try {
6775
- await expect(
6776
- (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6777
- ).rejects.toThrow('declared size exceeds the allowed limit');
6778
- expect(extractEntry).not.toHaveBeenCalled();
6779
- } finally {
6780
- loadSpy.mockRestore();
6781
- }
6782
- });
6783
-
6784
- test('normalizes the deprecated boot_resources target to resource', () => {
6785
- const method = new FirmwareUpdateV4({
6786
- id: 1,
6787
- payload: {
6788
- method: 'firmwareUpdateV4',
6789
- platform: 'web',
6790
- targetsToUpdate: ['boot_resources'],
6166
+ ],
6791
6167
  },
6792
6168
  });
6793
-
6794
6169
  method.init();
6795
- expect((method as any).params.targetsToUpdate).toEqual(['resource']);
6796
- });
6797
-
6798
- test('keeps the legacy componentArtifacts and artifactReader path without a prepared Plan', async () => {
6799
- const artifactReader = {
6800
- open: jest.fn(),
6801
- read: jest.fn(),
6802
- close: jest.fn(),
6803
- };
6804
- const method = new FirmwareUpdateV4({
6805
- id: 1,
6806
- payload: {
6807
- method: 'firmwareUpdateV4',
6808
- platform: 'web',
6809
- targetsToUpdate: ['boot'],
6810
- artifactReader,
6811
- componentArtifacts: {
6812
- boot: {
6813
- artifactRef: 'legacy-bootloader',
6814
- size: 3,
6815
- sha256: 'a'.repeat(64),
6816
- },
6817
- },
6818
- },
6819
- });
6820
-
6821
- expect(() => method.init()).not.toThrow();
6822
6170
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6823
- (method as any).device = stubDevice({
6824
- originalDescriptor: { protocolType: 'V2' },
6825
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
6826
- });
6827
- (method as any).runProtocolV2PreparedArtifacts = jest.fn().mockResolvedValue('legacy-result');
6828
-
6829
- await expect(method.run()).resolves.toBe('legacy-result');
6830
- expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalledTimes(1);
6831
- });
6832
6171
 
6833
- test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
6834
- const method = new FirmwareUpdateV4({
6835
- id: 1,
6836
- payload: {
6837
- method: 'firmwareUpdateV4',
6838
- platform: 'web',
6839
- },
6840
- });
6841
- method.init();
6842
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6843
6172
  (method as any).device = stubDevice({
6844
6173
  originalDescriptor: { protocolType: 'V2' },
6845
6174
  features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6846
6175
  });
6847
- forceReloadDataSpy.mockRejectedValue(
6848
- ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
6849
- );
6850
6176
  (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6851
6177
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
6852
6178
  method.postTipMessage = jest.fn();
6853
6179
 
6854
- await expect(method.run()).rejects.toMatchObject({
6855
- errorCode: HardwareErrorCode.NetworkError,
6856
- message: 'latest config unavailable',
6857
- });
6858
-
6180
+ await expect(method.run()).rejects.toThrow('resourceFiles[0].devicePath');
6859
6181
  expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6860
6182
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
6861
6183
  });
6862
6184
 
6863
- test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
6864
- const method = new FirmwareUpdateV4({
6865
- id: 1,
6866
- payload: {
6867
- method: 'firmwareUpdateV4',
6868
- platform: 'web',
6869
- coprocessorBinary: new Uint8Array([1]).buffer,
6870
- },
6871
- });
6872
- method.init();
6873
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6874
-
6875
- (method as any).device = stubDevice({
6876
- originalDescriptor: { protocolType: 'V2' },
6877
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6878
- });
6879
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6880
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
6881
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
6882
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
6883
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
6884
- bootloaderVersion: '1.0.0',
6885
- bleVersion: '0.0.0',
6886
- firmwareVersion: '1.0.0',
6887
- });
6888
- method.postTipMessage = jest.fn();
6889
-
6890
- await method.run();
6891
-
6892
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6893
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
6894
- bootloaderBinary: null,
6895
- fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
6896
- });
6897
- });
6898
-
6899
- test('syncs prepared resource sources without sending a firmware install request', async () => {
6185
+ test('syncs resource bundles without sending a firmware install request', async () => {
6900
6186
  const method = new FirmwareUpdateV4({
6901
6187
  id: 1,
6902
6188
  payload: {
@@ -6913,70 +6199,23 @@ describe('Protocol V2 firmware update targets', () => {
6913
6199
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
6914
6200
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
6915
6201
 
6916
- await (method as any).executeProtocolV2SourceUpdate({
6917
- installSources: [],
6918
- resourceSources: [
6202
+ await (method as any).executeProtocolV2Update({
6203
+ resourceBundles: [
6919
6204
  {
6920
6205
  name: 'images.okpkg',
6921
- source: {
6922
- size: 3,
6923
- readAt: jest.fn(),
6924
- close: jest.fn(),
6925
- },
6206
+ binary: new Uint8Array([1, 2, 3]).buffer,
6926
6207
  devicePath: 'vol0:/resource/images/images.okpkg',
6927
6208
  },
6928
6209
  ],
6210
+ bootloaderBinary: null,
6211
+ fwBinaryMap: [],
6929
6212
  });
6930
6213
 
6931
6214
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
6932
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
6933
- expect.objectContaining({ filePath: 'vol0:/resource/images/images.okpkg' })
6934
- );
6935
- expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
6936
- 'vol0:/resource/images/images.okpkg',
6937
- 3
6938
- );
6939
6215
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
6940
6216
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
6941
6217
  });
6942
6218
 
6943
- test('writes the mounted boot resource package through its staging path', async () => {
6944
- const method = new FirmwareUpdateV4({
6945
- id: 1,
6946
- payload: {
6947
- method: 'firmwareUpdateV4',
6948
- },
6949
- });
6950
-
6951
- method.postTipMessage = jest.fn();
6952
- method.postProgressMessage = jest.fn();
6953
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
6954
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
6955
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
6956
- (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
6957
-
6958
- await (method as any).executeProtocolV2SourceUpdate({
6959
- installSources: [],
6960
- resourceSources: [
6961
- {
6962
- name: 'boot_resource.okpkg',
6963
- source: {
6964
- size: 3,
6965
- readAt: jest.fn(),
6966
- close: jest.fn(),
6967
- },
6968
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6969
- },
6970
- ],
6971
- });
6972
-
6973
- const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
6974
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
6975
- expect.objectContaining({ filePath: stagingPath })
6976
- );
6977
- expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
6978
- });
6979
-
6980
6219
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
6981
6220
  const method = new FirmwareUpdateV4({
6982
6221
  id: 1,
@@ -7680,6 +6919,64 @@ describe('Protocol V2 firmware update method', () => {
7680
6919
  });
7681
6920
 
7682
6921
  describe('Protocol V2 firmware reconnect identity', () => {
6922
+ const createResourceFilesystemTypedCall = (
6923
+ stable: Array<{ type: string; size: number; headerHash: string }>,
6924
+ missingTypes: string[] = []
6925
+ ) => {
6926
+ const missing = new Set(missingTypes);
6927
+ const resourceByPath = new Map(
6928
+ stable.map(resource => [
6929
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6930
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6931
+ ],
6932
+ resource,
6933
+ ])
6934
+ );
6935
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6936
+ if (requestType === 'ResourceInventoryGet') {
6937
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
6938
+ }
6939
+ if (requestType === 'FilesystemPathInfoQuery') {
6940
+ const resource = resourceByPath.get(payload.path);
6941
+ const exists = Boolean(resource && !missing.has(resource.type));
6942
+ return {
6943
+ message: {
6944
+ exist: exists,
6945
+ directory: false,
6946
+ size: exists ? resource?.size : 0,
6947
+ },
6948
+ };
6949
+ }
6950
+ if (requestType === 'FilesystemFileRead') {
6951
+ const resource = resourceByPath.get(payload.file.path);
6952
+ if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6953
+ const header = new Uint8Array(0x52a0);
6954
+ const view = new DataView(header.buffer);
6955
+ 'OKPP'.split('').forEach((char, index) => {
6956
+ header[index] = char.charCodeAt(0);
6957
+ });
6958
+ 'RESC'.split('').forEach((char, index) => {
6959
+ header[0x08 + index] = char.charCodeAt(0);
6960
+ });
6961
+ view.setUint32(0x0c, header.byteLength, true);
6962
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
6963
+ header[0x240 + index] = Number.parseInt(
6964
+ resource.headerHash.slice(index * 2, index * 2 + 2),
6965
+ 16
6966
+ );
6967
+ }
6968
+ const offset = Number(payload.file.offset);
6969
+ const chunkLength = Number(payload.chunk_len);
6970
+ return {
6971
+ message: {
6972
+ data: header.slice(offset, offset + chunkLength),
6973
+ },
6974
+ };
6975
+ }
6976
+ throw new Error(`Unexpected request: ${requestType}`);
6977
+ });
6978
+ };
6979
+
7683
6980
  test('keeps the host plan identity as the V4 reconnect anchor', () => {
7684
6981
  const method = new FirmwareUpdateV4({
7685
6982
  id: 1,
@@ -7746,7 +7043,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
7746
7043
  );
7747
7044
  });
7748
7045
 
7749
- test('uses the connection route when DeviceInfo has no physical serial', async () => {
7046
+ test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
7750
7047
  const method = new FirmwareUpdateV4({
7751
7048
  id: 1,
7752
7049
  payload: { method: 'firmwareUpdateV4' },
@@ -7756,96 +7053,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
7756
7053
  message: { protocol_version: 1, hw: {} },
7757
7054
  });
7758
7055
  (method as any).device = stubDevice({
7759
- originalDescriptor: { path: '' },
7760
- getConnectId: () => '000000000000',
7761
7056
  getCommands: () => ({ typedCall }),
7762
7057
  });
7763
7058
 
7764
- await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
7765
- expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
7766
- expect((method as any).protocolV2ExpectedPath).toBe('000000000000');
7767
- });
7768
-
7769
- test('executes a serial-less Pro2 prepared plan when the live model matches', async () => {
7770
- const planWithoutDigest = {
7771
- schemaVersion: 2 as const,
7772
- executor: 'v4' as const,
7773
- deviceIdentity: 'unavailable',
7774
- deviceModel: 'pro2',
7775
- firmwareType: EFirmwareType.Universal,
7776
- platform: 'web' as const,
7777
- targetsToUpdate: ['boot' as const],
7778
- artifacts: [
7779
- {
7780
- artifactId: 'component:boot',
7781
- role: 'component' as const,
7782
- target: 'boot' as const,
7783
- url: 'https://example.com/bootloader.bin',
7784
- container: 'raw' as const,
7785
- logicalName: 'bootloader',
7786
- },
7787
- ],
7788
- };
7789
- const preparedPlan = prepareFirmwareUpdatePlan({
7790
- plan: {
7791
- ...planWithoutDigest,
7792
- planDigest: digestFirmwareUpdateContract(planWithoutDigest),
7793
- },
7794
- leaseRef: 'serial-less-v4-executor-test',
7795
- artifacts: [
7796
- {
7797
- artifactId: 'component:boot',
7798
- artifact: {
7799
- artifactRef: 'serial-less-bootloader',
7800
- size: 1,
7801
- sha256: 'a'.repeat(64),
7802
- },
7803
- },
7804
- ],
7805
- });
7806
- const hostBindingGeneration = registerFirmwareUpdateHostBinding({
7807
- preparedPlanDigest: preparedPlan.preparedPlanDigest,
7808
- artifactReader: {
7809
- open: jest.fn(),
7810
- read: jest.fn(),
7811
- close: jest.fn(),
7812
- },
7059
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
7060
+ errorCode: HardwareErrorCode.DeviceNotFound,
7813
7061
  });
7814
- try {
7815
- const method = new FirmwareUpdateV4({
7816
- id: 1,
7817
- payload: {
7818
- method: 'firmwareUpdateV4',
7819
- platform: 'web',
7820
- preparedPlan,
7821
- hostBindingGeneration,
7822
- },
7823
- });
7824
- method.init();
7825
- const typedCall = jest.fn().mockResolvedValue({
7826
- type: 'DeviceInfo',
7827
- message: { protocol_version: 1, hw: {} },
7828
- });
7829
- (method as any).device = stubDevice({
7830
- originalDescriptor: { protocolType: 'V2', path: 'serial-less-pro2' },
7831
- features: {
7832
- deviceType: 'pro2',
7833
- firmwareVersion: '1.0.0',
7834
- capabilities: [],
7835
- },
7836
- getCommands: () => ({ typedCall }),
7837
- });
7838
- (method as any).runProtocolV2PreparedArtifacts = jest
7839
- .fn()
7840
- .mockResolvedValue('serial-less-result');
7841
-
7842
- await expect(method.run()).resolves.toBe('serial-less-result');
7843
- expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalled();
7844
- expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
7845
- expect((method as any).protocolV2ExpectedPath).toBe('serial-less-pro2');
7846
- } finally {
7847
- unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
7848
- }
7849
7062
  });
7850
7063
 
7851
7064
  test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
@@ -7878,94 +7091,292 @@ describe('Protocol V2 firmware reconnect identity', () => {
7878
7091
  expect(Math.max(...readChunkLengths)).toBe(900);
7879
7092
  });
7880
7093
 
7881
- test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
7094
+ test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
7882
7095
  const method = new FirmwareUpdateV4({
7883
7096
  id: 1,
7884
7097
  payload: {
7885
7098
  method: 'firmwareUpdateV4',
7886
7099
  platform: 'web',
7100
+ targetsToUpdate: ['resource'],
7887
7101
  },
7888
7102
  });
7889
7103
  method.init();
7890
- const installedBinary = new Uint8Array(createProtocolV2OkppBinary());
7891
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
7892
- if (requestType === 'FilesystemPathInfoQuery') {
7104
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
7105
+ (type, index) => ({
7106
+ type,
7107
+ url: `https://example.com/${type}.okpkg`,
7108
+ size: 0x52a0 + index + 1,
7109
+ fileHash: 'a'.repeat(64),
7110
+ headerHash: index.toString(16).padStart(128, '0'),
7111
+ })
7112
+ );
7113
+ const typedCall = createResourceFilesystemTypedCall(stable);
7114
+ (method as any).device = stubDevice({
7115
+ getCommands: () => ({ typedCall }),
7116
+ getCurrentDeviceType: () => 'pro2',
7117
+ });
7118
+ const resourcesSpy = jest
7119
+ .spyOn(DataManager, 'getProtocolV2Resources')
7120
+ .mockReturnValue(stable as any);
7121
+ (method as any).downloadProtocolV2Resource = jest.fn();
7122
+
7123
+ await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
7124
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
7125
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
7126
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
7127
+ resourcesSpy.mockRestore();
7128
+ });
7129
+
7130
+ test('downloads only changed resources from loader inventory', async () => {
7131
+ const method = new FirmwareUpdateV4({
7132
+ id: 1,
7133
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7134
+ });
7135
+ method.init();
7136
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
7137
+ (type, index) => ({
7138
+ type,
7139
+ url: `https://example.com/${type}.okpkg`,
7140
+ size: 0x52a0 + index + 1,
7141
+ fileHash: 'a'.repeat(64),
7142
+ headerHash: index.toString(16).padStart(128, '0'),
7143
+ })
7144
+ );
7145
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
7146
+ (method as any).device = stubDevice({
7147
+ getCommands: () => ({ typedCall }),
7148
+ getCurrentDeviceType: () => 'pro2',
7149
+ });
7150
+ const resourcesSpy = jest
7151
+ .spyOn(DataManager, 'getProtocolV2Resources')
7152
+ .mockReturnValue(stable as any);
7153
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
7154
+ Promise.resolve({
7155
+ name: `${resource.type}.okpkg`,
7156
+ binary: new ArrayBuffer(resource.size),
7157
+ devicePath: `vol0:/${resource.type}.okpkg`,
7158
+ })
7159
+ );
7160
+
7161
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
7162
+
7163
+ expect(bundles).toHaveLength(1);
7164
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
7165
+ resourcesSpy.mockRestore();
7166
+ });
7167
+
7168
+ test('uses filesystem inventory for incremental Bootloader recovery', async () => {
7169
+ const method = new FirmwareUpdateV4({
7170
+ id: 1,
7171
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7172
+ });
7173
+ method.init();
7174
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
7175
+ (type, index) => ({
7176
+ type,
7177
+ url: `https://example.com/${type}.okpkg`,
7178
+ size: 0x52a0 + index + 1,
7179
+ fileHash: 'a'.repeat(64),
7180
+ headerHash: index.toString(16).padStart(128, '0'),
7181
+ })
7182
+ );
7183
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
7184
+ (method as any).device = stubDevice({
7185
+ getCommands: () => ({ typedCall }),
7186
+ getCurrentDeviceType: () => 'pro2',
7187
+ });
7188
+ const resourcesSpy = jest
7189
+ .spyOn(DataManager, 'getProtocolV2Resources')
7190
+ .mockReturnValue(stable as any);
7191
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
7192
+ Promise.resolve({
7193
+ name: `${resource.type}.okpkg`,
7194
+ binary: new ArrayBuffer(resource.size),
7195
+ devicePath: `vol0:/${resource.type}.okpkg`,
7196
+ })
7197
+ );
7198
+
7199
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
7200
+
7201
+ expect(bundles).toHaveLength(1);
7202
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
7203
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
7204
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
7205
+ resourcesSpy.mockRestore();
7206
+ });
7207
+
7208
+ test('does not resolve boot resources when no resource target is selected', async () => {
7209
+ const method = new FirmwareUpdateV4({
7210
+ id: 1,
7211
+ payload: { method: 'firmwareUpdateV4', platform: 'web' },
7212
+ });
7213
+ method.init();
7214
+ const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
7215
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
7216
+
7217
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
7218
+
7219
+ expect(configSpy).not.toHaveBeenCalled();
7220
+ expect(downloadSpy).not.toHaveBeenCalled();
7221
+ });
7222
+
7223
+ test('continues a normal resource update when boot resources are not configured', async () => {
7224
+ const method = new FirmwareUpdateV4({
7225
+ id: 1,
7226
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7227
+ });
7228
+ method.init();
7229
+ (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
7230
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(undefined);
7231
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
7232
+
7233
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
7234
+ expect(downloadSpy).not.toHaveBeenCalled();
7235
+ });
7236
+
7237
+ test('requires boot configuration for an explicit boot_resources target', async () => {
7238
+ const method = new FirmwareUpdateV4({
7239
+ id: 1,
7240
+ payload: {
7241
+ method: 'firmwareUpdateV4',
7242
+ platform: 'web',
7243
+ targetsToUpdate: ['boot_resources'],
7244
+ },
7245
+ });
7246
+ method.init();
7247
+ (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
7248
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(undefined);
7249
+
7250
+ await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
7251
+ 'Missing Protocol V2 boot resources configuration'
7252
+ );
7253
+ });
7254
+
7255
+ test('skips downloading a boot resource when the installed SHA-256 matches', async () => {
7256
+ const bytes = new Uint8Array([1, 2, 3, 4]);
7257
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
7258
+ const resource = {
7259
+ required: false as const,
7260
+ target: 'RES' as const,
7261
+ files: [
7262
+ {
7263
+ name: 'bootloader_crest.bin',
7264
+ url: 'https://example.com/bootloader_crest.bin',
7265
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7266
+ size: bytes.byteLength,
7267
+ fileHash,
7268
+ },
7269
+ ],
7270
+ };
7271
+ const typedCall = jest.fn((name: string, _response: string, payload: any) => {
7272
+ if (name === 'FilesystemPathInfoQuery') {
7893
7273
  return Promise.resolve({
7894
- message: { exist: true, directory: false, size: installedBinary.byteLength },
7274
+ message: { exist: true, directory: false, size: bytes.byteLength },
7895
7275
  });
7896
7276
  }
7897
- if (requestType === 'FilesystemFileRead') {
7277
+ if (name === 'FilesystemFileRead') {
7278
+ const offset = Number(payload.file.offset);
7898
7279
  return Promise.resolve({
7899
- message: {
7900
- data: installedBinary.slice(
7901
- Number(request.file.offset),
7902
- Number(request.file.offset) + Number(request.chunk_len)
7903
- ),
7904
- },
7280
+ message: { data: bytes.slice(offset, offset + Number(payload.chunk_len)) },
7905
7281
  });
7906
7282
  }
7907
- throw new Error(`Unexpected request: ${requestType}`);
7283
+ return Promise.reject(new Error(`Unexpected request: ${name}`));
7908
7284
  });
7285
+ const method = new FirmwareUpdateV4({
7286
+ id: 1,
7287
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7288
+ });
7289
+ method.init();
7909
7290
  (method as any).device = stubDevice({
7291
+ getCurrentDeviceType: () => 'pro2',
7910
7292
  getCommands: () => ({ typedCall }),
7911
7293
  });
7912
- method.postTipMessage = jest.fn();
7913
- method.postProgressMessage = jest.fn();
7914
- (method as any).protocolV2SourceUpdateProcess = jest.fn();
7294
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
7295
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
7296
+
7297
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([]);
7298
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
7299
+ 'FilesystemPathInfoQuery',
7300
+ 'FilesystemFileRead',
7301
+ ]);
7302
+ expect(downloadSpy).not.toHaveBeenCalled();
7303
+ });
7915
7304
 
7916
- await (method as any).executeProtocolV2TransferPhase({
7917
- installSources: [],
7918
- resourceSources: [
7305
+ test('rejects duplicate device paths across explicit and remote resource sources', () => {
7306
+ const method = new FirmwareUpdateV4({
7307
+ id: 1,
7308
+ payload: { method: 'firmwareUpdateV4' },
7309
+ });
7310
+ const devicePath = 'vol0:/assets/shared.bin';
7311
+
7312
+ expect(() =>
7313
+ (method as any).mergeProtocolV2ResourceBundles(
7314
+ [{ name: 'local.bin', binary: new ArrayBuffer(1), devicePath }],
7315
+ [{ name: 'remote.bin', binary: new ArrayBuffer(1), devicePath }]
7316
+ )
7317
+ ).toThrow(`Duplicate Protocol V2 resource devicePath: ${devicePath}`);
7318
+ });
7319
+
7320
+ test('includes startup resources in the complete resource target', async () => {
7321
+ const bytes = new Uint8Array([1, 2, 3, 4]);
7322
+ const binary = bytes.buffer as ArrayBuffer;
7323
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
7324
+ const resource = {
7325
+ required: false as const,
7326
+ target: 'RES' as const,
7327
+ files: [
7919
7328
  {
7920
- name: 'images.okpkg',
7921
- source: { size: installedBinary.byteLength },
7922
- devicePath: 'vol0:/bundles/images/images.okpkg',
7923
- version: [1, 2, 3],
7924
- payloadHash: '11'.repeat(64),
7925
- headerHash: '22'.repeat(64),
7329
+ name: 'bootloader_crest.bin',
7330
+ url: 'https://example.com/bootloader_crest.bin',
7331
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7332
+ size: bytes.byteLength,
7333
+ fileHash,
7926
7334
  },
7927
7335
  ],
7336
+ };
7337
+ const method = new FirmwareUpdateV4({
7338
+ id: 1,
7339
+ payload: {
7340
+ method: 'firmwareUpdateV4',
7341
+ platform: 'web',
7342
+ targetsToUpdate: ['resource'],
7343
+ },
7928
7344
  });
7345
+ method.init();
7346
+ (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
7347
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
7348
+ jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
7929
7349
 
7930
- expect(typedCall).toHaveBeenCalledWith('FilesystemPathInfoQuery', 'FilesystemPathInfo', {
7931
- path: 'vol0:/bundles/images/images.okpkg',
7932
- });
7933
- expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
7350
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([
7351
+ {
7352
+ name: 'bootloader_crest.bin',
7353
+ binary,
7354
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7355
+ },
7356
+ ]);
7934
7357
  });
7935
7358
 
7936
- test('updates a resource when the installed file size differs despite matching headers', async () => {
7359
+ test('rejects a manually supplied resource file when its manifest hash does not match', () => {
7937
7360
  const method = new FirmwareUpdateV4({
7938
7361
  id: 1,
7939
7362
  payload: {
7940
7363
  method: 'firmwareUpdateV4',
7941
7364
  platform: 'web',
7365
+ resourceFiles: [
7366
+ {
7367
+ binary: new Uint8Array([1, 2, 3]).buffer,
7368
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7369
+ size: 3,
7370
+ fileHash: '00'.repeat(32),
7371
+ },
7372
+ ],
7942
7373
  },
7943
7374
  });
7944
7375
  method.init();
7945
- const installedBinary = new Uint8Array(createProtocolV2OkppBinary());
7946
- const typedCall = jest.fn((requestType: string) => {
7947
- if (requestType === 'FilesystemPathInfoQuery') {
7948
- return Promise.resolve({
7949
- message: { exist: true, directory: false, size: installedBinary.byteLength + 1 },
7950
- });
7951
- }
7952
- throw new Error(`Unexpected request: ${requestType}`);
7953
- });
7954
- (method as any).device = stubDevice({
7955
- getCommands: () => ({ typedCall }),
7956
- });
7957
7376
 
7958
- await expect(
7959
- (method as any).isProtocolV2ResourceBundleUpToDate({
7960
- name: 'images.okpkg',
7961
- source: { size: installedBinary.byteLength },
7962
- devicePath: 'vol0:/bundles/images/images.okpkg',
7963
- version: [1, 2, 3],
7964
- payloadHash: '11'.repeat(64),
7965
- headerHash: '22'.repeat(64),
7966
- })
7967
- ).resolves.toBe(false);
7968
- expect(typedCall).toHaveBeenCalledTimes(1);
7377
+ expect(() => (method as any).prepareExplicitProtocolV2ResourceFiles()).toThrow(
7378
+ 'SHA-256 mismatch'
7379
+ );
7969
7380
  });
7970
7381
  });
7971
7382