@onekeyfe/hd-core 1.2.0-alpha.98 → 1.2.0
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.
- package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +20 -4
- package/__tests__/DeviceCommands.test.ts +203 -27
- package/__tests__/base64Data.test.ts +69 -0
- package/__tests__/check-all-firmware-release-protocol-v2.test.ts +89 -38
- package/__tests__/check-firmware-release-protocol-v2.test.ts +83 -14
- package/__tests__/device-connector-protocol.test.ts +21 -0
- package/__tests__/device-lifecycle-events.test.ts +427 -7
- package/__tests__/device-pool-state.test.ts +25 -0
- package/__tests__/device-settings.test.ts +237 -9
- package/__tests__/device-state-events.test.ts +2 -2
- package/__tests__/device-state-mapper.test.ts +61 -5
- package/__tests__/device-utils.test.ts +15 -1
- package/__tests__/device-wallet-session-store.test.ts +147 -21
- package/__tests__/deviceSettings.test.ts +7 -3
- package/__tests__/deviceUploadNft.test.ts +36 -4
- package/__tests__/firmware-update/device-update-bootloader.test.ts +14 -3
- package/__tests__/firmware-update/firmware-artifact-source.test.ts +61 -9
- package/__tests__/firmware-update/firmware-prepared-host-digest.test.ts +19 -9
- package/__tests__/firmware-update/firmware-prepared-resource-archive.test.ts +21 -0
- package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +128 -2
- package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +80 -0
- package/__tests__/firmware-update/firmware-update-plan.test.ts +114 -50
- package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +19 -5
- package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +116 -0
- package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +678 -0
- package/__tests__/firmware-update/firmware-update-v4-parameters.test.ts +85 -0
- package/__tests__/get-device-state.test.ts +60 -21
- package/__tests__/logBlockEvent.test.ts +13 -1
- package/__tests__/open-wallet-session.test.ts +127 -1
- package/__tests__/protocol-v2-firmware-targets.test.ts +39 -0
- package/__tests__/protocol-v2-resources.test.ts +185 -88
- package/__tests__/protocol-v2-unlock-policy.test.ts +235 -2
- package/__tests__/protocol-v2.test.ts +2274 -466
- package/__tests__/refresh-device-state.test.ts +3 -1
- package/__tests__/resourceBase64Boundary.test.ts +48 -0
- package/__tests__/search-devices.test.ts +79 -32
- package/__tests__/ton-sign-message.test.ts +84 -0
- package/dist/api/BaseMethod.d.ts +2 -1
- package/dist/api/BaseMethod.d.ts.map +1 -1
- package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
- package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
- package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
- package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
- package/dist/api/FirmwareUpdateV4.d.ts +22 -7
- package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
- package/dist/api/OpenWalletSession.d.ts.map +1 -1
- package/dist/api/SearchDevices.d.ts.map +1 -1
- package/dist/api/UploadPortfolio.d.ts +1 -1
- package/dist/api/UploadPortfolio.d.ts.map +1 -1
- package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
- package/dist/api/device/DeviceChangePin.d.ts.map +1 -1
- package/dist/api/device/DeviceRebootToBoardloader.d.ts.map +1 -1
- package/dist/api/device/DeviceRebootToBootloader.d.ts.map +1 -1
- package/dist/api/device/DeviceSettings.d.ts.map +1 -1
- package/dist/api/device/DeviceVerify.d.ts +1 -0
- package/dist/api/device/DeviceVerify.d.ts.map +1 -1
- package/dist/api/device/DeviceWipe.d.ts.map +1 -1
- package/dist/api/firmware/FirmwareArtifactSource.d.ts +5 -1
- package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
- package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -1
- package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts.map +1 -1
- package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +2 -2
- package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
- package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -15
- package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -1
- package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +1 -0
- package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -1
- package/dist/api/firmware/getBinary.d.ts +3 -2
- package/dist/api/firmware/getBinary.d.ts.map +1 -1
- package/dist/api/firmware/protocolV2Release.d.ts.map +1 -1
- package/dist/api/helpers/base64Data.d.ts +16 -0
- package/dist/api/helpers/base64Data.d.ts.map +1 -0
- package/dist/api/protocol-v2/DeviceFirmwareUpdate.d.ts +3 -1
- package/dist/api/protocol-v2/DeviceFirmwareUpdate.d.ts.map +1 -1
- package/dist/api/protocol-v2/DeviceGetFirmwareUpdateStatus.d.ts.map +1 -1
- package/dist/api/protocol-v2/DeviceInfoGet.d.ts +1 -1
- package/dist/api/protocol-v2/DeviceInfoGet.d.ts.map +1 -1
- package/dist/api/protocol-v2/DeviceReboot.d.ts +1 -1
- package/dist/api/protocol-v2/DeviceReboot.d.ts.map +1 -1
- package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
- package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
- package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
- package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
- package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
- package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
- package/dist/core/index.d.ts +6 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/data-manager/DataManager.d.ts +4 -3
- package/dist/data-manager/DataManager.d.ts.map +1 -1
- package/dist/data-manager/TransportManager.d.ts.map +1 -1
- package/dist/device/Device.d.ts +16 -3
- package/dist/device/Device.d.ts.map +1 -1
- package/dist/device/DeviceCommands.d.ts +4 -4
- package/dist/device/DeviceCommands.d.ts.map +1 -1
- package/dist/device/DeviceConnector.d.ts +1 -1
- package/dist/device/DeviceConnector.d.ts.map +1 -1
- package/dist/device/DevicePool.d.ts.map +1 -1
- package/dist/device/DeviceStateMapper.d.ts.map +1 -1
- package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
- package/dist/events/logBlockEvent.d.ts.map +1 -1
- package/dist/events/ui-request.d.ts +3 -0
- package/dist/events/ui-request.d.ts.map +1 -1
- package/dist/index.d.ts +54 -84
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2333 -1515
- package/dist/protocols/protocol-v2/features.d.ts +13 -5
- package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/index.d.ts +2 -2
- package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/resources.d.ts +12 -7
- package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +3 -1
- package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/walletSession.d.ts +3 -0
- package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
- package/dist/types/api/firmwareUpdatePlan.d.ts +2 -2
- package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -1
- package/dist/types/api/protocolV2.d.ts +1 -1
- package/dist/types/api/protocolV2.d.ts.map +1 -1
- package/dist/types/device.d.ts.map +1 -1
- package/dist/types/settings.d.ts +3 -31
- package/dist/types/settings.d.ts.map +1 -1
- package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
- package/dist/utils/deviceFeaturesUtils.d.ts +3 -0
- package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
- package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
- package/dist/utils/deviceSettings.d.ts +1 -0
- package/dist/utils/deviceSettings.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/patch.d.ts +1 -1
- package/dist/utils/patch.d.ts.map +1 -1
- package/dist/utils/pro2Nft.d.ts +7 -0
- package/dist/utils/pro2Nft.d.ts.map +1 -1
- package/package.json +6 -4
- package/src/api/BaseMethod.ts +8 -1
- package/src/api/CheckAllFirmwareRelease.ts +8 -5
- package/src/api/CheckBLEFirmwareRelease.ts +2 -2
- package/src/api/CheckBootloaderRelease.ts +3 -2
- package/src/api/CheckFirmwareRelease.ts +2 -2
- package/src/api/FirmwareUpdateV2.ts +130 -69
- package/src/api/FirmwareUpdateV3.ts +29 -19
- package/src/api/FirmwareUpdateV4.ts +966 -743
- package/src/api/OpenWalletSession.ts +39 -9
- package/src/api/PromptWebDeviceAccess.ts +2 -2
- package/src/api/SearchDevices.ts +6 -3
- package/src/api/UploadPortfolio.ts +9 -2
- package/src/api/allnetwork/AllNetworkGetAddressBase.ts +2 -1
- package/src/api/device/DeviceChangePin.ts +4 -1
- package/src/api/device/DeviceRebootToBoardloader.ts +3 -1
- package/src/api/device/DeviceRebootToBootloader.ts +3 -1
- package/src/api/device/DeviceSettings.ts +34 -20
- package/src/api/device/DeviceVerify.ts +25 -0
- package/src/api/device/DeviceWipe.ts +4 -1
- package/src/api/firmware/FirmwareArtifactSource.ts +41 -14
- package/src/api/firmware/FirmwareHostBinding.ts +10 -0
- package/src/api/firmware/FirmwarePreparedResourceArchive.ts +16 -27
- package/src/api/firmware/FirmwareUpdateBaseMethod.ts +131 -76
- package/src/api/firmware/FirmwareUpdatePlan.ts +55 -82
- package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +32 -15
- package/src/api/firmware/protocolV2Release.ts +7 -2
- package/src/api/helpers/base64Data.ts +85 -0
- package/src/api/protocol-v2/DeviceFactoryInfoSet.ts +1 -1
- package/src/api/protocol-v2/DeviceFirmwareUpdate.ts +6 -28
- package/src/api/protocol-v2/DeviceGetFirmwareUpdateStatus.ts +7 -1
- package/src/api/protocol-v2/DeviceInfoGet.ts +3 -3
- package/src/api/protocol-v2/DeviceReboot.ts +5 -0
- package/src/api/protocol-v2/DeviceUploadNft.ts +61 -9
- package/src/api/protocol-v2/DeviceUploadWallpaper.ts +53 -19
- package/src/api/protocol-v2/helpers.ts +8 -0
- package/src/api/ton/TonSignMessage.ts +5 -3
- package/src/core/index.ts +264 -55
- package/src/data/messages/messages-protocol-v2.json +140 -13
- package/src/data-manager/DataManager.ts +46 -39
- package/src/data-manager/TransportManager.ts +3 -1
- package/src/device/Device.ts +210 -44
- package/src/device/DeviceCommands.ts +27 -18
- package/src/device/DeviceConnector.ts +17 -3
- package/src/device/DevicePool.ts +9 -2
- package/src/device/DeviceStateMapper.ts +30 -24
- package/src/deviceProfile/buildDeviceFeatures.ts +10 -5
- package/src/events/logBlockEvent.ts +14 -1
- package/src/events/ui-request.ts +3 -0
- package/src/index.ts +0 -10
- package/src/protocols/protocol-v2/features.ts +22 -5
- package/src/protocols/protocol-v2/index.ts +2 -0
- package/src/protocols/protocol-v2/resources.ts +110 -147
- package/src/protocols/protocol-v2/unlockPolicyRunner.ts +13 -1
- package/src/protocols/protocol-v2/walletSession.ts +65 -22
- package/src/types/api/firmwareUpdate.ts +1 -1
- package/src/types/api/firmwareUpdatePlan.ts +2 -2
- package/src/types/api/protocolV2.ts +1 -1
- package/src/types/device.ts +2 -1
- package/src/types/settings.ts +6 -34
- package/src/utils/deviceFeaturesCompat.ts +9 -4
- package/src/utils/deviceFeaturesUtils.ts +4 -0
- package/src/utils/deviceInfoUtils.ts +9 -2
- package/src/utils/deviceSettings.ts +11 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/pro2Nft.ts +31 -8
- package/__tests__/firmware-memory-host.test.ts +0 -112
- package/dist/api/firmware/FirmwareMemoryHost.d.ts +0 -22
- package/dist/api/firmware/FirmwareMemoryHost.d.ts.map +0 -1
- package/src/api/firmware/FirmwareMemoryHost.ts +0 -142
|
@@ -203,6 +203,69 @@ describe.each([
|
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
describe('FirmwareUpdateV2 WebUSB bootloader polling', () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
jest.clearAllTimers();
|
|
213
|
+
jest.useRealTimers();
|
|
214
|
+
jest.restoreAllMocks();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('skips interval ticks while a bootloader probe is in flight and resumes afterward', async () => {
|
|
218
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('desktop' as never);
|
|
219
|
+
jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(false);
|
|
220
|
+
jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(false);
|
|
221
|
+
|
|
222
|
+
const method = new FirmwareUpdateV2({
|
|
223
|
+
id: 1,
|
|
224
|
+
payload: { method: 'firmwareUpdateV2', connectId: WEBUSB_ID },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
let inFlight = 0;
|
|
228
|
+
let maxInFlight = 0;
|
|
229
|
+
const resolveProbes: Array<(found: boolean) => void> = [];
|
|
230
|
+
const probe = jest.spyOn(method as any, '_checkDeviceInBootloaderMode').mockImplementation(
|
|
231
|
+
() =>
|
|
232
|
+
new Promise<boolean>(resolve => {
|
|
233
|
+
inFlight += 1;
|
|
234
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
235
|
+
resolveProbes.push(found => {
|
|
236
|
+
inFlight -= 1;
|
|
237
|
+
resolve(found);
|
|
238
|
+
});
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
method.device = {
|
|
243
|
+
getCurrentDeviceType: jest.fn(() => EDeviceType.Classic),
|
|
244
|
+
} as unknown as Device;
|
|
245
|
+
|
|
246
|
+
method.checkDeviceToBootloader(WEBUSB_ID);
|
|
247
|
+
|
|
248
|
+
for (let elapsed = 0; elapsed < 6500; elapsed += 500) {
|
|
249
|
+
jest.advanceTimersByTime(500);
|
|
250
|
+
// eslint-disable-next-line no-await-in-loop
|
|
251
|
+
await flush();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
expect(probe).toHaveBeenCalled();
|
|
255
|
+
expect(maxInFlight).toBe(1);
|
|
256
|
+
|
|
257
|
+
resolveProbes[0]?.(false);
|
|
258
|
+
await flush();
|
|
259
|
+
jest.advanceTimersByTime(2000);
|
|
260
|
+
await flush();
|
|
261
|
+
|
|
262
|
+
expect(probe).toHaveBeenCalledTimes(2);
|
|
263
|
+
expect(maxInFlight).toBe(1);
|
|
264
|
+
resolveProbes[1]?.(false);
|
|
265
|
+
await flush();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
206
269
|
describe.each([
|
|
207
270
|
[
|
|
208
271
|
'FirmwareUpdateV2',
|
|
@@ -231,7 +294,7 @@ describe.each([
|
|
|
231
294
|
jest.restoreAllMocks();
|
|
232
295
|
});
|
|
233
296
|
|
|
234
|
-
test('
|
|
297
|
+
test('resumes bounded polling after browser authorization returns an empty enumeration', async () => {
|
|
235
298
|
jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
|
|
236
299
|
jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(false);
|
|
237
300
|
jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(true);
|
|
@@ -300,13 +363,76 @@ describe.each([
|
|
|
300
363
|
}
|
|
301
364
|
expect(rejected).not.toHaveBeenCalled();
|
|
302
365
|
|
|
303
|
-
permissionGranted = true;
|
|
304
366
|
resolvePrompt('bootloader-device');
|
|
305
367
|
await flush();
|
|
306
368
|
await flush();
|
|
307
369
|
|
|
370
|
+
// The chooser may resolve before the newly authorized device appears in the
|
|
371
|
+
// next WebUSB enumeration. That transient empty result must not strand the
|
|
372
|
+
// deferred check after its original interval and timeout were cleared.
|
|
373
|
+
expect(resolved).not.toHaveBeenCalled();
|
|
374
|
+
expect(rejected).not.toHaveBeenCalled();
|
|
375
|
+
|
|
376
|
+
permissionGranted = true;
|
|
377
|
+
for (let elapsed = 0; elapsed < 5000; elapsed += 500) {
|
|
378
|
+
jest.advanceTimersByTime(500);
|
|
379
|
+
// eslint-disable-next-line no-await-in-loop
|
|
380
|
+
await flush();
|
|
381
|
+
if (resolved.mock.calls.length) break;
|
|
382
|
+
}
|
|
383
|
+
|
|
308
384
|
expect(resolved).toHaveBeenCalledWith(true);
|
|
309
385
|
expect(updateFromCache).toHaveBeenCalledWith(bootloaderDevice);
|
|
310
386
|
expect(commands.disposed).toBe(false);
|
|
311
387
|
});
|
|
388
|
+
|
|
389
|
+
test('rejects after the fresh deadline when the authorized device never appears', async () => {
|
|
390
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
|
|
391
|
+
jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(false);
|
|
392
|
+
jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(true);
|
|
393
|
+
jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({ devices: {}, deviceList: [] });
|
|
394
|
+
|
|
395
|
+
const method = buildMethod();
|
|
396
|
+
method.device = {
|
|
397
|
+
originalDescriptor: {
|
|
398
|
+
id: WEBUSB_ID,
|
|
399
|
+
path: WEBUSB_ID,
|
|
400
|
+
protocolType: 'V1',
|
|
401
|
+
},
|
|
402
|
+
deviceConnector: {
|
|
403
|
+
enumerate: jest.fn().mockResolvedValue({ descriptors: [] }),
|
|
404
|
+
},
|
|
405
|
+
commands: { disposed: true },
|
|
406
|
+
updateFromCache: jest.fn(),
|
|
407
|
+
getCurrentDeviceType: jest.fn(() => EDeviceType.Pro),
|
|
408
|
+
} as unknown as Device;
|
|
409
|
+
method.postTipMessage = jest.fn();
|
|
410
|
+
const prompt = jest
|
|
411
|
+
.spyOn(method as any, '_promptDeviceInBootloaderForWebDevice')
|
|
412
|
+
.mockResolvedValue('bootloader-device');
|
|
413
|
+
|
|
414
|
+
method.checkDeviceToBootloader(WEBUSB_ID);
|
|
415
|
+
const resolved = jest.fn();
|
|
416
|
+
const rejected = jest.fn();
|
|
417
|
+
method.checkPromise?.promise.then(resolved, rejected);
|
|
418
|
+
|
|
419
|
+
for (let elapsed = 0; elapsed < 15000; elapsed += 500) {
|
|
420
|
+
jest.advanceTimersByTime(500);
|
|
421
|
+
// eslint-disable-next-line no-await-in-loop
|
|
422
|
+
await flush();
|
|
423
|
+
if (prompt.mock.calls.length) break;
|
|
424
|
+
}
|
|
425
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
426
|
+
|
|
427
|
+
for (let elapsed = 0; elapsed < 31000; elapsed += 500) {
|
|
428
|
+
jest.advanceTimersByTime(500);
|
|
429
|
+
// eslint-disable-next-line no-await-in-loop
|
|
430
|
+
await flush();
|
|
431
|
+
if (rejected.mock.calls.length) break;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
expect(resolved).not.toHaveBeenCalled();
|
|
435
|
+
expect(rejected).toHaveBeenCalledTimes(1);
|
|
436
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
437
|
+
});
|
|
312
438
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared';
|
|
2
2
|
|
|
3
3
|
import { buildFirmwareUpdatePlan } from '../../src/api/firmware/FirmwareUpdatePlan';
|
|
4
|
+
import {
|
|
5
|
+
registerFirmwareUpdateHostBinding,
|
|
6
|
+
unregisterFirmwareUpdateHostBinding,
|
|
7
|
+
} from '../../src/api/firmware/FirmwareHostBinding';
|
|
4
8
|
import {
|
|
5
9
|
assertFirmwareUpdatePreparedPlanDeviceIdentity,
|
|
6
10
|
prepareFirmwareUpdatePlan,
|
|
@@ -179,6 +183,19 @@ describe('firmware update plan identity in bootloader mode', () => {
|
|
|
179
183
|
],
|
|
180
184
|
});
|
|
181
185
|
};
|
|
186
|
+
const createBinding = (preparedPlanDigest: string) => ({
|
|
187
|
+
preparedPlanDigest,
|
|
188
|
+
artifactReader: {
|
|
189
|
+
open: () => Promise.resolve({ readerId: 'unused', size: 0 }),
|
|
190
|
+
read: () =>
|
|
191
|
+
Promise.resolve({
|
|
192
|
+
data: new ArrayBuffer(0),
|
|
193
|
+
bytesRead: 0,
|
|
194
|
+
eof: true,
|
|
195
|
+
}),
|
|
196
|
+
close: () => Promise.resolve(),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
182
199
|
|
|
183
200
|
test('accepts the phase that runs after the device reboots and reports a serial', () => {
|
|
184
201
|
const preparedPlan = prepareRecoveryPlan();
|
|
@@ -250,6 +267,69 @@ describe('firmware update plan identity in bootloader mode', () => {
|
|
|
250
267
|
})
|
|
251
268
|
).not.toThrow();
|
|
252
269
|
});
|
|
270
|
+
|
|
271
|
+
test('releases the identity pin when the digest-bound host workflow ends', () => {
|
|
272
|
+
const preparedPlan = prepareRecoveryPlan();
|
|
273
|
+
const firstGeneration = registerFirmwareUpdateHostBinding(
|
|
274
|
+
createBinding(preparedPlan.preparedPlanDigest)
|
|
275
|
+
);
|
|
276
|
+
try {
|
|
277
|
+
assertFirmwareUpdatePreparedPlanDeviceIdentity({
|
|
278
|
+
preparedPlan,
|
|
279
|
+
deviceIdentity: 'CLA45F0023',
|
|
280
|
+
bootloaderMode: false,
|
|
281
|
+
deviceModel: String(EDeviceType.Classic1s),
|
|
282
|
+
});
|
|
283
|
+
} finally {
|
|
284
|
+
expect(unregisterFirmwareUpdateHostBinding(firstGeneration)).toBe(true);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const secondGeneration = registerFirmwareUpdateHostBinding(
|
|
288
|
+
createBinding(preparedPlan.preparedPlanDigest)
|
|
289
|
+
);
|
|
290
|
+
try {
|
|
291
|
+
expect(() =>
|
|
292
|
+
assertFirmwareUpdatePreparedPlanDeviceIdentity({
|
|
293
|
+
preparedPlan,
|
|
294
|
+
deviceIdentity: 'CLA99Z9999',
|
|
295
|
+
bootloaderMode: false,
|
|
296
|
+
deviceModel: String(EDeviceType.Classic1s),
|
|
297
|
+
})
|
|
298
|
+
).not.toThrow();
|
|
299
|
+
} finally {
|
|
300
|
+
expect(unregisterFirmwareUpdateHostBinding(secondGeneration)).toBe(true);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('a stale unregister cannot release the active workflow identity pin', () => {
|
|
305
|
+
const stalePlan = prepareRecoveryPlan();
|
|
306
|
+
const staleGeneration = registerFirmwareUpdateHostBinding(
|
|
307
|
+
createBinding(stalePlan.preparedPlanDigest)
|
|
308
|
+
);
|
|
309
|
+
const activePlan = prepareRecoveryPlan();
|
|
310
|
+
const activeGeneration = registerFirmwareUpdateHostBinding(
|
|
311
|
+
createBinding(activePlan.preparedPlanDigest)
|
|
312
|
+
);
|
|
313
|
+
try {
|
|
314
|
+
assertFirmwareUpdatePreparedPlanDeviceIdentity({
|
|
315
|
+
preparedPlan: activePlan,
|
|
316
|
+
deviceIdentity: 'CLA45F0023',
|
|
317
|
+
bootloaderMode: false,
|
|
318
|
+
deviceModel: String(EDeviceType.Classic1s),
|
|
319
|
+
});
|
|
320
|
+
expect(unregisterFirmwareUpdateHostBinding(staleGeneration)).toBe(false);
|
|
321
|
+
expect(() =>
|
|
322
|
+
assertFirmwareUpdatePreparedPlanDeviceIdentity({
|
|
323
|
+
preparedPlan: activePlan,
|
|
324
|
+
deviceIdentity: 'CLA99Z9999',
|
|
325
|
+
bootloaderMode: false,
|
|
326
|
+
deviceModel: String(EDeviceType.Classic1s),
|
|
327
|
+
})
|
|
328
|
+
).toThrow();
|
|
329
|
+
} finally {
|
|
330
|
+
expect(unregisterFirmwareUpdateHostBinding(activeGeneration)).toBe(true);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
253
333
|
});
|
|
254
334
|
|
|
255
335
|
describe('prepared plan device identity assertion', () => {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildFirmwareUpdatePlan,
|
|
5
|
+
validateProtocolV2FirmwareUpdateTargets,
|
|
6
|
+
} from '../../src/api/firmware/FirmwareUpdatePlan';
|
|
4
7
|
import {
|
|
5
8
|
assertFirmwareUpdatePreparedPlanDeviceIdentity,
|
|
6
9
|
prepareFirmwareUpdatePlan,
|
|
@@ -88,6 +91,18 @@ const createProtocolV2ForceInput = (
|
|
|
88
91
|
forceUpdateTargets: forceUpdateTargets as BuildPlanInput['forceUpdateTargets'],
|
|
89
92
|
});
|
|
90
93
|
|
|
94
|
+
describe('validateProtocolV2FirmwareUpdateTargets', () => {
|
|
95
|
+
test('normalizes the deprecated boot_resources alias to resource', () => {
|
|
96
|
+
expect(validateProtocolV2FirmwareUpdateTargets(['boot_resources'])).toEqual(['resource']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('rejects duplicate semantic targets after alias normalization', () => {
|
|
100
|
+
expectFirmwarePlanInvalid(() =>
|
|
101
|
+
validateProtocolV2FirmwareUpdateTargets(['boot_resources', 'resource'])
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
91
106
|
describe('buildFirmwareUpdatePlan', () => {
|
|
92
107
|
test('selects all legacy artifacts before execution and chooses the desktop full resource', () => {
|
|
93
108
|
const plan = buildFirmwareUpdatePlan({
|
|
@@ -316,65 +331,114 @@ describe('buildFirmwareUpdatePlan', () => {
|
|
|
316
331
|
expectFirmwarePlanInvalid(() => buildFirmwareUpdatePlan(createLegacyForceInput(['resource'])));
|
|
317
332
|
});
|
|
318
333
|
|
|
319
|
-
test(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
test.each(['firmware', 'ble', 'bootloader'] as const)(
|
|
334
|
-
'rejects a remote legacy %s artifact without complete integrity metadata',
|
|
335
|
-
target => {
|
|
334
|
+
test.each([
|
|
335
|
+
{
|
|
336
|
+
executor: 'V2',
|
|
337
|
+
deviceType: EDeviceType.Classic1s,
|
|
338
|
+
bootloaderVersion: '2.1.2',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
executor: 'V3',
|
|
342
|
+
deviceType: EDeviceType.Pro,
|
|
343
|
+
bootloaderVersion: '2.8.4',
|
|
344
|
+
},
|
|
345
|
+
])(
|
|
346
|
+
'rejects a resource-only prepared plan that the $executor executor cannot run',
|
|
347
|
+
({ deviceType, bootloaderVersion }) => {
|
|
336
348
|
expectFirmwarePlanInvalid(
|
|
337
349
|
() =>
|
|
338
350
|
buildFirmwareUpdatePlan({
|
|
339
|
-
features: createFeatures({
|
|
351
|
+
features: createFeatures({
|
|
352
|
+
deviceType,
|
|
353
|
+
firmwareVersion: '4.21.0',
|
|
354
|
+
bootloaderVersion,
|
|
355
|
+
}),
|
|
340
356
|
firmwareType: EFirmwareType.Universal,
|
|
341
357
|
platform: 'desktop',
|
|
342
|
-
firmware:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
webUpdate: 'https://firmware.onekey.so/classic/ble.bin',
|
|
358
|
-
expectedSize: 512,
|
|
359
|
-
},
|
|
360
|
-
}
|
|
361
|
-
: noUpdate,
|
|
362
|
-
bootloader:
|
|
363
|
-
target === 'bootloader'
|
|
364
|
-
? {
|
|
365
|
-
status: 'outdated',
|
|
366
|
-
release: {
|
|
367
|
-
bootloaderResource: 'https://firmware.onekey.so/classic/bootloader.bin',
|
|
368
|
-
bootloaderExpectedSize: 768,
|
|
369
|
-
},
|
|
370
|
-
}
|
|
371
|
-
: noUpdate,
|
|
358
|
+
firmware: {
|
|
359
|
+
status: 'valid',
|
|
360
|
+
release: {
|
|
361
|
+
url: 'https://firmware.onekey.so/legacy/firmware.bin',
|
|
362
|
+
version: [4, 21, 0],
|
|
363
|
+
expectedSize: 1024,
|
|
364
|
+
fingerprint: '1'.repeat(64),
|
|
365
|
+
resource: 'https://firmware.onekey.so/legacy/resource.zip',
|
|
366
|
+
resourceExpectedSize: 4096,
|
|
367
|
+
resourceFingerprint: '2'.repeat(64),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
ble: noUpdate,
|
|
371
|
+
bootloader: noUpdate,
|
|
372
|
+
forceUpdateTargets: ['resource'],
|
|
372
373
|
}),
|
|
373
|
-
'
|
|
374
|
+
'Legacy resource updates require a firmware target'
|
|
374
375
|
);
|
|
375
376
|
}
|
|
376
377
|
);
|
|
377
378
|
|
|
379
|
+
test.each([
|
|
380
|
+
{
|
|
381
|
+
executor: 'v2',
|
|
382
|
+
deviceType: EDeviceType.Classic1s,
|
|
383
|
+
bootloaderVersion: '2.1.2',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
executor: 'v3',
|
|
387
|
+
deviceType: EDeviceType.Pro,
|
|
388
|
+
bootloaderVersion: '2.8.4',
|
|
389
|
+
},
|
|
390
|
+
])(
|
|
391
|
+
'allows $executor config releases without optional integrity metadata',
|
|
392
|
+
({ executor, deviceType, bootloaderVersion }) => {
|
|
393
|
+
const plan = buildFirmwareUpdatePlan({
|
|
394
|
+
features: createFeatures({
|
|
395
|
+
deviceType,
|
|
396
|
+
firmwareVersion: '4.21.0',
|
|
397
|
+
bootloaderVersion,
|
|
398
|
+
}),
|
|
399
|
+
firmwareType: EFirmwareType.Universal,
|
|
400
|
+
platform: 'desktop',
|
|
401
|
+
firmware: {
|
|
402
|
+
status: 'valid',
|
|
403
|
+
release: {
|
|
404
|
+
url: 'https://firmware.onekey.so/legacy/firmware.bin',
|
|
405
|
+
version: [4, 21, 0],
|
|
406
|
+
fingerprint: '',
|
|
407
|
+
resource: 'https://firmware.onekey.so/legacy/resource.zip',
|
|
408
|
+
resourceFingerprint: '',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
ble: {
|
|
412
|
+
status: 'valid',
|
|
413
|
+
release: {
|
|
414
|
+
webUpdate: 'https://firmware.onekey.so/legacy/ble.bin',
|
|
415
|
+
version: [2, 3, 7],
|
|
416
|
+
fingerprintWeb: '',
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
bootloader: {
|
|
420
|
+
status: 'valid',
|
|
421
|
+
release: {
|
|
422
|
+
bootloaderResource: 'https://firmware.onekey.so/legacy/bootloader.bin',
|
|
423
|
+
bootloaderVersion: [2, 8, 4],
|
|
424
|
+
bootloaderFingerprint: '',
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
forceUpdateTargets: ['firmware', 'resource', 'ble', 'bootloader'],
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(plan.executor).toBe(executor);
|
|
431
|
+
expect(plan.artifacts.map(artifact => artifact.artifactId)).toEqual([
|
|
432
|
+
'bootloader',
|
|
433
|
+
'firmware',
|
|
434
|
+
'resource',
|
|
435
|
+
'ble',
|
|
436
|
+
]);
|
|
437
|
+
expect(plan.artifacts.every(artifact => artifact.expectedSize === undefined)).toBe(true);
|
|
438
|
+
expect(plan.artifacts.every(artifact => artifact.expectedSha256 === undefined)).toBe(true);
|
|
439
|
+
}
|
|
440
|
+
);
|
|
441
|
+
|
|
378
442
|
test.each(['ble', 'bootloader'] as const)(
|
|
379
443
|
'rejects the unsupported Pro2 %s force target',
|
|
380
444
|
forceTarget => {
|
|
@@ -179,7 +179,7 @@ describe('FirmwareUpdatePreparedPlan', () => {
|
|
|
179
179
|
).toThrow('plan digest is invalid');
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
test('
|
|
182
|
+
test('keeps ZIP entries unique by archive path, not basename', () => {
|
|
183
183
|
const plan = buildPlan();
|
|
184
184
|
const buildArtifacts = (entryNames: string[]) => [
|
|
185
185
|
{
|
|
@@ -200,7 +200,17 @@ describe('FirmwareUpdatePreparedPlan', () => {
|
|
|
200
200
|
prepareFirmwareUpdatePlan({
|
|
201
201
|
plan,
|
|
202
202
|
leaseRef: 'fwlease:00000000-0000-4000-8000-000000000001',
|
|
203
|
-
artifacts: buildArtifacts([
|
|
203
|
+
artifacts: buildArtifacts([
|
|
204
|
+
'loaders/bootloader/params-pro2-prod_resource-unsigned.okpkg',
|
|
205
|
+
'loaders/rom/params-pro2-prod_resource-unsigned.okpkg',
|
|
206
|
+
]),
|
|
207
|
+
})
|
|
208
|
+
).not.toThrow();
|
|
209
|
+
expect(() =>
|
|
210
|
+
prepareFirmwareUpdatePlan({
|
|
211
|
+
plan,
|
|
212
|
+
leaseRef: 'fwlease:00000000-0000-4000-8000-000000000001',
|
|
213
|
+
artifacts: buildArtifacts(['loaders/rom/params.okpkg', 'loaders/rom/params.okpkg']),
|
|
204
214
|
})
|
|
205
215
|
).toThrow('duplicate entry names');
|
|
206
216
|
expect(() =>
|
|
@@ -234,7 +244,11 @@ describe('FirmwareUpdatePreparedPlan', () => {
|
|
|
234
244
|
},
|
|
235
245
|
],
|
|
236
246
|
});
|
|
237
|
-
const resourceArtifact = prepared.artifacts.find(item => item.target === 'resource')
|
|
247
|
+
const resourceArtifact = prepared.artifacts.find(item => item.target === 'resource');
|
|
248
|
+
expect(resourceArtifact).toBeDefined();
|
|
249
|
+
if (!resourceArtifact) {
|
|
250
|
+
throw new Error('Expected prepared resource artifact');
|
|
251
|
+
}
|
|
238
252
|
const tamperedWithoutDigest = {
|
|
239
253
|
...prepared,
|
|
240
254
|
artifacts: prepared.artifacts.map(item =>
|
|
@@ -242,9 +256,9 @@ describe('FirmwareUpdatePreparedPlan', () => {
|
|
|
242
256
|
? {
|
|
243
257
|
...item,
|
|
244
258
|
materializedEntries: [
|
|
245
|
-
...resourceArtifact.materializedEntries
|
|
259
|
+
...(resourceArtifact.materializedEntries ?? []),
|
|
246
260
|
{
|
|
247
|
-
entryName: '
|
|
261
|
+
entryName: 'resource/index.bin',
|
|
248
262
|
artifact: artifact('d'.repeat(64), 2),
|
|
249
263
|
},
|
|
250
264
|
],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as hdShared from '@onekeyfe/hd-shared';
|
|
2
2
|
|
|
3
3
|
import FirmwareUpdateV3 from '../../src/api/FirmwareUpdateV3';
|
|
4
|
+
import { DataManager } from '../../src/data-manager';
|
|
4
5
|
import { DevicePool } from '../../src/device/DevicePool';
|
|
5
6
|
|
|
6
7
|
import type { Device } from '../../src/device/Device';
|
|
@@ -112,10 +113,125 @@ describe('FirmwareUpdateV3 reconnect', () => {
|
|
|
112
113
|
})
|
|
113
114
|
).rejects.toBe(reconnectError);
|
|
114
115
|
|
|
116
|
+
expect(commands.typedCall).toHaveBeenCalledWith('GetFeatures', 'Features', {});
|
|
115
117
|
expect(waitForDeviceReconnect).toHaveBeenCalledWith(3 * 60 * 1000);
|
|
116
118
|
} finally {
|
|
117
119
|
jest.clearAllTimers();
|
|
118
120
|
jest.useRealTimers();
|
|
119
121
|
}
|
|
120
122
|
});
|
|
123
|
+
|
|
124
|
+
it('lets Browser WebUSB clean up a timed out install probe before reconnecting', async () => {
|
|
125
|
+
jest.spyOn(hdShared, 'wait').mockResolvedValue(undefined);
|
|
126
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
|
|
127
|
+
jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(true);
|
|
128
|
+
const method = new FirmwareUpdateV3({
|
|
129
|
+
id: 1,
|
|
130
|
+
payload: {
|
|
131
|
+
method: 'firmwareUpdateV3',
|
|
132
|
+
connectId: 'webusb-device',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const reconnectError = new Error('stop after WebUSB reconnect assertion');
|
|
136
|
+
const waitForDeviceReconnect = jest
|
|
137
|
+
.spyOn(method, 'waitForDeviceReconnect')
|
|
138
|
+
.mockRejectedValue(reconnectError);
|
|
139
|
+
method.isBleReconnect = jest.fn(() => false);
|
|
140
|
+
method.params = {
|
|
141
|
+
platform: 'web',
|
|
142
|
+
} as any;
|
|
143
|
+
const commands = {
|
|
144
|
+
typedCall: jest.fn().mockRejectedValue(new Error('Protocol V1 read timeout after 3000ms')),
|
|
145
|
+
};
|
|
146
|
+
method.device = {
|
|
147
|
+
features: {},
|
|
148
|
+
getCommands: () => commands,
|
|
149
|
+
} as unknown as Device;
|
|
150
|
+
(method as any).createUpdatesFolderIfNotExists = jest.fn().mockResolvedValue(undefined);
|
|
151
|
+
(method as any).startEmmcFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
|
|
152
|
+
method.postTipMessage = jest.fn();
|
|
153
|
+
method.postProcessingMessage = jest.fn();
|
|
154
|
+
method.postProgressMessage = jest.fn();
|
|
155
|
+
|
|
156
|
+
await expect(
|
|
157
|
+
(method as any).executeUpdate({
|
|
158
|
+
resourceBinary: null,
|
|
159
|
+
resourceEntries: [],
|
|
160
|
+
fwSources: [],
|
|
161
|
+
bootloaderSource: null,
|
|
162
|
+
})
|
|
163
|
+
).rejects.toBe(reconnectError);
|
|
164
|
+
|
|
165
|
+
expect(commands.typedCall).toHaveBeenCalledWith(
|
|
166
|
+
'GetFeatures',
|
|
167
|
+
'Features',
|
|
168
|
+
{},
|
|
169
|
+
{
|
|
170
|
+
timeoutMs: 3000,
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
expect(waitForDeviceReconnect).toHaveBeenCalledWith(60 * 1000);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does not treat cached firmware versions as Browser WebUSB install completion', async () => {
|
|
177
|
+
jest.spyOn(hdShared, 'wait').mockResolvedValue(undefined);
|
|
178
|
+
jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
|
|
179
|
+
jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(true);
|
|
180
|
+
const method = new FirmwareUpdateV3({
|
|
181
|
+
id: 1,
|
|
182
|
+
payload: {
|
|
183
|
+
method: 'firmwareUpdateV3',
|
|
184
|
+
connectId: 'webusb-device',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
method.params = {
|
|
188
|
+
platform: 'web',
|
|
189
|
+
} as any;
|
|
190
|
+
const commands = {
|
|
191
|
+
typedCall: jest
|
|
192
|
+
.fn()
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
type: 'Features',
|
|
195
|
+
message: {
|
|
196
|
+
major_version: 0,
|
|
197
|
+
minor_version: 0,
|
|
198
|
+
patch_version: 0,
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
.mockResolvedValueOnce({
|
|
202
|
+
type: 'Features',
|
|
203
|
+
message: {
|
|
204
|
+
major_version: 4,
|
|
205
|
+
minor_version: 21,
|
|
206
|
+
patch_version: 0,
|
|
207
|
+
onekey_ble_version: '2.3.7',
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
method.device = {
|
|
212
|
+
features: {
|
|
213
|
+
firmwareVersion: '4.21.0',
|
|
214
|
+
bleVersion: '2.3.7',
|
|
215
|
+
},
|
|
216
|
+
getCommands: () => commands,
|
|
217
|
+
} as unknown as Device;
|
|
218
|
+
(method as any).createUpdatesFolderIfNotExists = jest.fn().mockResolvedValue(undefined);
|
|
219
|
+
(method as any).startEmmcFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
|
|
220
|
+
method.postTipMessage = jest.fn();
|
|
221
|
+
method.postProcessingMessage = jest.fn();
|
|
222
|
+
method.postProgressMessage = jest.fn();
|
|
223
|
+
|
|
224
|
+
const result = await (method as any).executeUpdate({
|
|
225
|
+
resourceBinary: null,
|
|
226
|
+
resourceEntries: [],
|
|
227
|
+
fwSources: [],
|
|
228
|
+
bootloaderSource: null,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(commands.typedCall).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(result).toMatchObject({
|
|
233
|
+
firmwareVersion: '4.21.0',
|
|
234
|
+
bleVersion: '2.3.7',
|
|
235
|
+
});
|
|
236
|
+
});
|
|
121
237
|
});
|