@onekeyfe/hd-core 1.2.8-alpha.13 → 1.2.8-alpha.130

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 (125) hide show
  1. package/__tests__/allNetworkGetAddress-privacy-networks.test.ts +78 -0
  2. package/__tests__/core-dispose.test.ts +114 -1
  3. package/__tests__/core-error-output.test.ts +21 -0
  4. package/__tests__/device-connector-protocol.test.ts +5 -2
  5. package/__tests__/device-lifecycle-events.test.ts +118 -3
  6. package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
  7. package/__tests__/logBlockEvent.test.ts +292 -0
  8. package/__tests__/logger.test.ts +18 -0
  9. package/__tests__/monero-get-address.test.ts +147 -0
  10. package/__tests__/monero-key-image-sync.test.ts +272 -0
  11. package/__tests__/monero-sign-transaction.test.ts +436 -0
  12. package/__tests__/protocol-v2-resources.test.ts +14 -0
  13. package/__tests__/protocol-v2.test.ts +845 -3
  14. package/__tests__/protocolV2FileWrite.test.ts +434 -7
  15. package/__tests__/search-devices.test.ts +36 -0
  16. package/__tests__/usb-identity-probe.test.ts +358 -0
  17. package/__tests__/zcash-protobuf.test.ts +141 -0
  18. package/__tests__/zcash-sign-pczt.test.ts +446 -0
  19. package/dist/api/BaseMethod.d.ts.map +1 -1
  20. package/dist/api/FileWrite.d.ts.map +1 -1
  21. package/dist/api/FirmwareUpdateV4.d.ts +5 -0
  22. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  23. package/dist/api/SearchDevices.d.ts +21 -1
  24. package/dist/api/SearchDevices.d.ts.map +1 -1
  25. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  26. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  27. package/dist/api/helpers/pathUtils.d.ts +1 -0
  28. package/dist/api/helpers/pathUtils.d.ts.map +1 -1
  29. package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
  30. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  31. package/dist/api/index.d.ts +6 -0
  32. package/dist/api/index.d.ts.map +1 -1
  33. package/dist/api/monero/MoneroGetAddress.d.ts +11 -0
  34. package/dist/api/monero/MoneroGetAddress.d.ts.map +1 -0
  35. package/dist/api/monero/MoneroGetWatchKey.d.ts +11 -0
  36. package/dist/api/monero/MoneroGetWatchKey.d.ts.map +1 -0
  37. package/dist/api/monero/MoneroKeyImageSync.d.ts +20 -0
  38. package/dist/api/monero/MoneroKeyImageSync.d.ts.map +1 -0
  39. package/dist/api/monero/MoneroSignTransaction.d.ts +17 -0
  40. package/dist/api/monero/MoneroSignTransaction.d.ts.map +1 -0
  41. package/dist/api/monero/helpers.d.ts +5 -0
  42. package/dist/api/monero/helpers.d.ts.map +1 -0
  43. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  44. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  45. package/dist/api/zcash/ZcashGetAddress.d.ts +12 -0
  46. package/dist/api/zcash/ZcashGetAddress.d.ts.map +1 -0
  47. package/dist/api/zcash/ZcashSignPczt.d.ts +16 -0
  48. package/dist/api/zcash/ZcashSignPczt.d.ts.map +1 -0
  49. package/dist/core/index.d.ts +3 -1
  50. package/dist/core/index.d.ts.map +1 -1
  51. package/dist/device/Device.d.ts +1 -0
  52. package/dist/device/Device.d.ts.map +1 -1
  53. package/dist/device/DeviceCommands.d.ts +4 -4
  54. package/dist/events/logBlockEvent.d.ts +10 -0
  55. package/dist/events/logBlockEvent.d.ts.map +1 -1
  56. package/dist/events/ui-request.d.ts +2 -0
  57. package/dist/events/ui-request.d.ts.map +1 -1
  58. package/dist/index.d.ts +183 -7
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +5625 -4192
  61. package/dist/inject.d.ts.map +1 -1
  62. package/dist/protocols/protocol-v2/resources.d.ts +1 -0
  63. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  64. package/dist/types/api/allNetworkGetAddress.d.ts +5 -1
  65. package/dist/types/api/allNetworkGetAddress.d.ts.map +1 -1
  66. package/dist/types/api/export.d.ts +6 -0
  67. package/dist/types/api/export.d.ts.map +1 -1
  68. package/dist/types/api/index.d.ts +12 -0
  69. package/dist/types/api/index.d.ts.map +1 -1
  70. package/dist/types/api/moneroGetAddress.d.ts +15 -0
  71. package/dist/types/api/moneroGetAddress.d.ts.map +1 -0
  72. package/dist/types/api/moneroGetWatchKey.d.ts +15 -0
  73. package/dist/types/api/moneroGetWatchKey.d.ts.map +1 -0
  74. package/dist/types/api/moneroKeyImageSync.d.ts +24 -0
  75. package/dist/types/api/moneroKeyImageSync.d.ts.map +1 -0
  76. package/dist/types/api/moneroSignTransaction.d.ts +32 -0
  77. package/dist/types/api/moneroSignTransaction.d.ts.map +1 -0
  78. package/dist/types/api/zcashGetAddress.d.ts +22 -0
  79. package/dist/types/api/zcashGetAddress.d.ts.map +1 -0
  80. package/dist/types/api/zcashSignPczt.d.ts +9 -0
  81. package/dist/types/api/zcashSignPczt.d.ts.map +1 -0
  82. package/dist/types/device.d.ts +10 -0
  83. package/dist/types/device.d.ts.map +1 -1
  84. package/dist/utils/logger.d.ts.map +1 -1
  85. package/dist/utils/patch.d.ts +1 -1
  86. package/dist/utils/patch.d.ts.map +1 -1
  87. package/package.json +4 -4
  88. package/src/api/BaseMethod.ts +2 -0
  89. package/src/api/FileWrite.ts +7 -1
  90. package/src/api/FirmwareUpdateV4.ts +358 -74
  91. package/src/api/SearchDevices.ts +172 -7
  92. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +38 -1
  93. package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
  94. package/src/api/helpers/pathUtils.ts +23 -0
  95. package/src/api/helpers/protocolV2FileWrite.ts +375 -111
  96. package/src/api/index.ts +7 -0
  97. package/src/api/monero/MoneroGetAddress.ts +71 -0
  98. package/src/api/monero/MoneroGetWatchKey.ts +69 -0
  99. package/src/api/monero/MoneroKeyImageSync.ts +164 -0
  100. package/src/api/monero/MoneroSignTransaction.ts +319 -0
  101. package/src/api/monero/helpers.ts +28 -0
  102. package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
  103. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
  104. package/src/api/zcash/ZcashGetAddress.ts +119 -0
  105. package/src/api/zcash/ZcashSignPczt.ts +152 -0
  106. package/src/core/index.ts +49 -17
  107. package/src/data/messages/messages-protocol-v2.json +138 -1
  108. package/src/device/Device.ts +21 -3
  109. package/src/device/DeviceConnector.ts +4 -4
  110. package/src/events/logBlockEvent.ts +55 -3
  111. package/src/events/ui-request.ts +2 -0
  112. package/src/index.ts +1 -0
  113. package/src/inject.ts +13 -0
  114. package/src/protocols/protocol-v2/resources.ts +6 -1
  115. package/src/types/api/allNetworkGetAddress.ts +9 -1
  116. package/src/types/api/export.ts +15 -0
  117. package/src/types/api/index.ts +20 -0
  118. package/src/types/api/moneroGetAddress.ts +27 -0
  119. package/src/types/api/moneroGetWatchKey.ts +28 -0
  120. package/src/types/api/moneroKeyImageSync.ts +45 -0
  121. package/src/types/api/moneroSignTransaction.ts +54 -0
  122. package/src/types/api/zcashGetAddress.ts +35 -0
  123. package/src/types/api/zcashSignPczt.ts +18 -0
  124. package/src/types/device.ts +18 -0
  125. package/src/utils/logger.ts +4 -0
@@ -1,15 +1,442 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
-
3
- import { writeProtocolV2File } from '../src/api/helpers/protocolV2FileWrite';
1
+ import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
3
+
4
+ import {
5
+ createProtocolV2AssetFileWriteRecovery,
6
+ type ProtocolV2FileWriteOptions,
7
+ writeProtocolV2File,
8
+ } from '../src/api/helpers/protocolV2FileWrite';
4
9
  import { DataManager } from '../src/data-manager';
5
10
  import { LoggerNames, getLogger } from '../src/utils/logger';
6
11
 
12
+ import type { DeviceCommands } from '../src/device/DeviceCommands';
13
+ import type { BaseMethod } from '../src/api/BaseMethod';
14
+ import type { MessageFromOneKey, TransportCallOptions } from '@onekeyfe/hd-transport';
15
+
7
16
  jest.mock('../src/data/config', () => ({
8
17
  getSDKVersion: jest.fn(() => '1.0.0'),
9
18
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
10
19
  }));
11
20
 
12
21
  describe('writeProtocolV2File', () => {
22
+ describe('Pro2 / Neo asset pipelines', () => {
23
+ type Request = {
24
+ file: { path: string; offset: number; total_size: number; data: Uint8Array };
25
+ overwrite: boolean;
26
+ append: boolean;
27
+ ui_percentage: number;
28
+ };
29
+
30
+ const createCommands = (deviceType = EDeviceType.Pro2) => {
31
+ const requests: Request[] = [];
32
+ const device = {
33
+ isProtocolV2: () => true,
34
+ getCurrentDeviceType: () => deviceType,
35
+ state: { status: { mode: 'normal' }, versions: { firmware: '1.0.3', bootloader: '1.0.0' } },
36
+ };
37
+ const typedCall = jest.fn(
38
+ async (
39
+ _name: string,
40
+ _response: string,
41
+ first: Request,
42
+ options?: TransportCallOptions
43
+ ) => {
44
+ let request: Request | undefined = first;
45
+ let response: MessageFromOneKey = { type: 'FilesystemFile', message: {} };
46
+ while (request) {
47
+ requests.push(request);
48
+ response = {
49
+ type: 'FilesystemFile',
50
+ message: {
51
+ ...request.file,
52
+ processed_byte: request.file.offset + request.file.data.byteLength,
53
+ },
54
+ };
55
+ options?.fileWritePipeline?.onResponse(response);
56
+ request = (await options?.fileWritePipeline?.next()) as Request | undefined;
57
+ }
58
+ return response;
59
+ }
60
+ );
61
+ const commands = { device, typedCall } as unknown as DeviceCommands;
62
+ return { commands, device, typedCall, requests };
63
+ };
64
+
65
+ beforeEach(() => {
66
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
67
+ jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
68
+ });
69
+
70
+ afterEach(() => jest.restoreAllMocks());
71
+
72
+ test.each(
73
+ [EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
74
+ ['react-native', 'desktop-web-ble'].flatMap(env =>
75
+ [
76
+ 'vol1:/wallpapers/wallpaper.okpkg',
77
+ 'vol1:/nft/nft-deadbeef-1760000000000.okpkg',
78
+ 'vol1:/portfolio/portfolio.okpkg.pending',
79
+ ].map(path => ({ deviceType, env, path }))
80
+ )
81
+ )
82
+ )(
83
+ 'pipelines $deviceType $path on $env using application version',
84
+ async ({ deviceType, env, path }) => {
85
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
86
+ const { commands, typedCall, requests } = createCommands(deviceType);
87
+ const onProgress = jest.fn();
88
+
89
+ await expect(
90
+ writeProtocolV2File({
91
+ commands,
92
+ path,
93
+ data: new Uint8Array(3601),
94
+ overwrite: true,
95
+ bleChunkSizeLimit: 1960,
96
+ chunkSizeLimit: 4000,
97
+ onProgress,
98
+ })
99
+ ).resolves.toMatchObject({ processed_byte: 3601, chunks: 3 });
100
+
101
+ expect(typedCall).toHaveBeenCalledTimes(1);
102
+ expect(typedCall.mock.calls[0][3]).toMatchObject({
103
+ writeWithResponse: false,
104
+ fileWritePipeline: { next: expect.any(Function), onResponse: expect.any(Function) },
105
+ });
106
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1800, 3600]);
107
+ expect(requests.map(request => request.file.data.byteLength)).toEqual([1800, 1800, 1]);
108
+ expect(requests.map(request => request.overwrite)).toEqual([true, false, false]);
109
+ expect(onProgress.mock.calls.map(([progress]) => progress.transferredBytes)).toEqual([
110
+ 1800, 3600, 3601,
111
+ ]);
112
+ }
113
+ );
114
+
115
+ test('does not replay a failed pipeline on the invalidated link', async () => {
116
+ const { commands, typedCall } = createCommands();
117
+ const timeout = new Error('BLE response timeout');
118
+ typedCall.mockRejectedValueOnce(timeout);
119
+
120
+ await expect(
121
+ writeProtocolV2File({
122
+ commands,
123
+ overwrite: true,
124
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
125
+ data: new Uint8Array(3601),
126
+ maxChunkRetries: 3,
127
+ })
128
+ ).rejects.toBe(timeout);
129
+ expect(typedCall).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ test.each(
133
+ [EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
134
+ ['react-native', 'desktop-web-ble'].flatMap(env =>
135
+ [
136
+ 'vol1:/wallpapers/wallpaper.okpkg',
137
+ 'vol1:/nft/nft-deadbeef-1760000000000.okpkg',
138
+ 'vol1:/portfolio/portfolio.okpkg.pending',
139
+ ].map(path => ({ deviceType, env, path }))
140
+ )
141
+ )
142
+ )(
143
+ 'reconnects and restarts the dual-frame $deviceType $path upload on $env',
144
+ async ({ deviceType, env, path }) => {
145
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
146
+ const { commands, typedCall } = createCommands(deviceType);
147
+ const fresh = createCommands(deviceType);
148
+ const timeout = Object.assign(new Error('BLE response timeout'), {
149
+ errorCode: HardwareErrorCode.BleTimeoutError,
150
+ });
151
+ typedCall.mockImplementationOnce((_name, _response, first, options) => {
152
+ options?.fileWritePipeline?.onResponse({
153
+ type: 'FilesystemFile',
154
+ message: { ...first.file, processed_byte: 1800 },
155
+ });
156
+ return Promise.reject(timeout);
157
+ });
158
+ const recoverConnection = jest.fn().mockResolvedValue(fresh.commands);
159
+ const onProgress = jest.fn();
160
+ const metrics = jest.spyOn(getLogger(LoggerNames.Core), 'log');
161
+
162
+ await expect(
163
+ writeProtocolV2File({
164
+ commands,
165
+ overwrite: true,
166
+ path,
167
+ data: new Uint8Array(3601),
168
+ maxChunkRetries: 3,
169
+ recoverConnection,
170
+ onProgress,
171
+ })
172
+ ).resolves.toMatchObject({ processed_byte: 3601, chunks: 3 });
173
+
174
+ expect(typedCall).toHaveBeenCalledTimes(1);
175
+ expect(recoverConnection).toHaveBeenCalledTimes(1);
176
+ expect(metrics).toHaveBeenCalledWith(
177
+ expect.stringContaining(`status=failed path=${path} bytes=1800/3601`)
178
+ );
179
+ expect(metrics).toHaveBeenLastCalledWith(
180
+ expect.stringContaining('window=2 chunk=1800 retry=1 averageSpeed=')
181
+ );
182
+ expect(fresh.requests.map(request => request.file.offset)).toEqual([0, 1800, 3600]);
183
+ expect(fresh.requests.map(request => request.overwrite)).toEqual([true, false, false]);
184
+ expect(fresh.typedCall).toHaveBeenCalledTimes(1);
185
+ expect(fresh.typedCall.mock.calls[0][3]?.fileWritePipeline).toBeDefined();
186
+ expect(fresh.typedCall.mock.calls[0][3]?.timeoutMs).toBe(10_000);
187
+ expect(onProgress.mock.calls.map(([progress]) => progress.transferredBytes)).toEqual([
188
+ 1800, 0, 1800, 3600, 3601,
189
+ ]);
190
+ expect(onProgress.mock.calls[onProgress.mock.calls.length - 1][0]).toMatchObject({
191
+ retryCount: 1,
192
+ progress: 100,
193
+ });
194
+ }
195
+ );
196
+
197
+ test('bounds dual-frame whole-file recovery to three extra attempts', async () => {
198
+ const { commands, typedCall } = createCommands();
199
+ const timeout = Object.assign(new Error('BLE response timeout'), {
200
+ errorCode: HardwareErrorCode.BleTimeoutError,
201
+ });
202
+ typedCall.mockRejectedValue(timeout);
203
+ const recoverConnection = jest.fn().mockResolvedValue(commands);
204
+ await expect(
205
+ writeProtocolV2File({
206
+ commands,
207
+ overwrite: true,
208
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
209
+ data: new Uint8Array(3601),
210
+ maxChunkRetries: 3,
211
+ recoverConnection,
212
+ })
213
+ ).rejects.toBe(timeout);
214
+ expect(typedCall).toHaveBeenCalledTimes(4);
215
+ expect(recoverConnection).toHaveBeenCalledTimes(3);
216
+ expect(typedCall.mock.calls.every(call => !!call[3]?.fileWritePipeline)).toBe(true);
217
+ });
218
+
219
+ test('does not start a retry when cancelled during reconnect', async () => {
220
+ const { commands, typedCall } = createCommands();
221
+ const aborted = new Error('cancelled');
222
+ let cancelled = false;
223
+ typedCall.mockRejectedValueOnce(new Error('BLE response timeout'));
224
+ const recoverConnection = jest.fn(() => {
225
+ cancelled = true;
226
+ return Promise.resolve(commands);
227
+ });
228
+ await expect(
229
+ writeProtocolV2File({
230
+ commands,
231
+ overwrite: true,
232
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
233
+ data: new Uint8Array(3601),
234
+ maxChunkRetries: 3,
235
+ recoverConnection,
236
+ throwIfAborted: () => {
237
+ if (cancelled) throw aborted;
238
+ },
239
+ })
240
+ ).rejects.toBe(aborted);
241
+ expect(typedCall).toHaveBeenCalledTimes(1);
242
+ });
243
+
244
+ test('does not retry device rejections or callback failures', async () => {
245
+ const { commands, typedCall } = createCommands();
246
+ const failure = new Error('device rejected file');
247
+ typedCall.mockRejectedValueOnce(failure);
248
+ const recoverConnection = jest.fn();
249
+ await expect(
250
+ writeProtocolV2File({
251
+ commands,
252
+ overwrite: true,
253
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
254
+ data: new Uint8Array(3601),
255
+ maxChunkRetries: 3,
256
+ recoverConnection,
257
+ })
258
+ ).rejects.toBe(failure);
259
+ expect(recoverConnection).not.toHaveBeenCalled();
260
+ });
261
+
262
+ test.each(['different-device', ''])(
263
+ 'rejects reconnect identity %s before unlocking or writing',
264
+ async serialNo => {
265
+ const { commands, device, typedCall } = createCommands();
266
+ const state = {
267
+ ...device.state,
268
+ identity: { serialNo: 'test-device', deviceType: EDeviceType.Pro2 },
269
+ };
270
+ const acquire = jest.fn().mockResolvedValue(undefined);
271
+ const initialize = jest.fn(() => {
272
+ state.identity.serialNo = serialNo;
273
+ return Promise.resolve(undefined);
274
+ });
275
+ const method = {
276
+ device: { ...device, state, commands, acquire, initialize },
277
+ } as unknown as BaseMethod;
278
+ const recover = createProtocolV2AssetFileWriteRecovery(method);
279
+ await expect(recover()).rejects.toMatchObject({
280
+ errorCode: HardwareErrorCode.DeviceCheckDeviceIdError,
281
+ });
282
+ expect(acquire).toHaveBeenCalledWith('V2', { throwOnRunPromiseError: true });
283
+ expect(typedCall).not.toHaveBeenCalled();
284
+ }
285
+ );
286
+
287
+ test('refreshes unlock status after reconnect even when the original preflight completed', async () => {
288
+ const { commands, device, typedCall } = createCommands();
289
+ typedCall.mockResolvedValueOnce({ type: 'DeviceStatus', message: { unlocked: false } });
290
+ const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
291
+ const method = {
292
+ name: 'deviceUploadWallpaper',
293
+ device: {
294
+ ...device,
295
+ state: {
296
+ ...device.state,
297
+ identity: { serialNo: 'test-device', deviceType: EDeviceType.Pro2 },
298
+ status: { mode: 'normal', initialized: true },
299
+ },
300
+ commands,
301
+ acquire: jest.fn().mockResolvedValue(undefined),
302
+ initialize: jest.fn().mockResolvedValue(undefined),
303
+ updateProtocolV2Status: (status: unknown) => status,
304
+ unlockDevice,
305
+ toMessageObject: () => ({}),
306
+ },
307
+ unlockPolicy: 'unlock-before-run',
308
+ useDevicePassphraseState: false,
309
+ protocolV2PreUnlockPinType: DeviceSessionPinType.Any,
310
+ protocolV2UnlockContext: { preflightCompleted: true },
311
+ postMessage: jest.fn(),
312
+ payload: {},
313
+ } as unknown as BaseMethod;
314
+ await expect(createProtocolV2AssetFileWriteRecovery(method)()).resolves.toBe(commands);
315
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
316
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, expect.any(Object));
317
+ });
318
+
319
+ const serialCases: {
320
+ name: string;
321
+ firmware?: string;
322
+ protocol?: boolean;
323
+ mode?: string;
324
+ deviceType?: EDeviceType;
325
+ env?: string;
326
+ options?: Partial<ProtocolV2FileWriteOptions>;
327
+ }[] = [
328
+ { name: 'old application with a new bootloader', firmware: '1.0.2' },
329
+ { name: 'unknown application', firmware: '' },
330
+ { name: 'invalid application version', firmware: 'unknown' },
331
+ { name: 'application prerelease', firmware: '1.0.3-rc.1' },
332
+ { name: 'Protocol V1', protocol: false },
333
+ { name: 'bootloader runtime', mode: 'bootloader' },
334
+ { name: 'another model', deviceType: EDeviceType.Pro },
335
+ { name: 'WebUSB', env: 'web' },
336
+ { name: 'LowLevel transport', env: 'lowlevel' },
337
+ { name: 'a generic file', options: { path: 'vol1:/other.bin' } },
338
+ { name: 'a partial file', options: { totalSize: 7200 } },
339
+ { name: 'non-overwriting writes', options: { overwrite: false } },
340
+ { name: 'append', options: { append: true } },
341
+ { name: 'offset writes', options: { offset: 1 } },
342
+ { name: 'explicit pacing', options: { paceMs: 1 } },
343
+ { name: 'acknowledged characteristic writes', options: { writeWithResponse: true } },
344
+ ];
345
+
346
+ test.each(serialCases)('keeps $name serial', async testCase => {
347
+ const { commands, device, typedCall } = createCommands(testCase.deviceType);
348
+ device.isProtocolV2 = () => testCase.protocol ?? true;
349
+ device.state.versions.firmware = testCase.firmware ?? '1.0.3';
350
+ device.state.versions.bootloader = '9.0.0';
351
+ device.state.status.mode = testCase.mode ?? 'normal';
352
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(testCase.env ?? 'react-native');
353
+
354
+ await writeProtocolV2File({
355
+ commands,
356
+ overwrite: true,
357
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
358
+ data: new Uint8Array(3601),
359
+ ...testCase.options,
360
+ });
361
+
362
+ expect(typedCall.mock.calls.every(call => !call[3]?.fileWritePipeline)).toBe(true);
363
+ });
364
+
365
+ test('drains an in-flight response on cancellation before rejecting', async () => {
366
+ const { commands, typedCall } = createCommands();
367
+ const aborted = new Error('upload cancelled');
368
+ let cancelled = false;
369
+ const drained = jest.fn();
370
+ const onProgress = jest.fn();
371
+ typedCall.mockImplementationOnce(async (_name, _response, first, options) => {
372
+ const pipeline = options?.fileWritePipeline;
373
+ if (!pipeline) throw new Error('Expected a file write pipeline');
374
+ const second = (await pipeline.next()) as Request;
375
+ cancelled = true;
376
+ expect(() =>
377
+ pipeline.onResponse({ type: 'FilesystemFile', message: { processed_byte: 1800 } })
378
+ ).not.toThrow();
379
+ await expect(pipeline.next()).resolves.toBeUndefined();
380
+ const response = { type: 'FilesystemFile', message: { processed_byte: 3600 } };
381
+ pipeline.onResponse(response);
382
+ drained(first, second);
383
+ return response;
384
+ });
385
+
386
+ await expect(
387
+ writeProtocolV2File({
388
+ commands,
389
+ overwrite: true,
390
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
391
+ data: new Uint8Array(5401),
392
+ throwIfAborted: () => {
393
+ if (cancelled) throw aborted;
394
+ },
395
+ onProgress,
396
+ })
397
+ ).rejects.toBe(aborted);
398
+ expect(drained).toHaveBeenCalledTimes(1);
399
+ expect(onProgress).not.toHaveBeenCalled();
400
+ expect(typedCall).toHaveBeenCalledTimes(1);
401
+ });
402
+
403
+ test('raises a progress callback error after stopping further chunks', async () => {
404
+ const { commands, typedCall, requests } = createCommands();
405
+ const callbackError = new Error('progress callback failed');
406
+
407
+ await expect(
408
+ writeProtocolV2File({
409
+ commands,
410
+ overwrite: true,
411
+ path: 'vol1:/portfolio/portfolio.okpkg.pending',
412
+ data: new Uint8Array(3601),
413
+ onProgress: () => {
414
+ throw callbackError;
415
+ },
416
+ })
417
+ ).rejects.toBe(callbackError);
418
+ expect(requests).toHaveLength(1);
419
+ expect(typedCall).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ test('rejects a pipeline that returns without confirming every chunk', async () => {
423
+ const { commands, typedCall } = createCommands();
424
+ typedCall.mockResolvedValueOnce({
425
+ type: 'FilesystemFile',
426
+ message: { processed_byte: 3601 },
427
+ });
428
+ await expect(
429
+ writeProtocolV2File({
430
+ commands,
431
+ overwrite: true,
432
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
433
+ data: new Uint8Array(3601),
434
+ })
435
+ ).rejects.toThrow('before the complete file was confirmed');
436
+ expect(typedCall).toHaveBeenCalledTimes(1);
437
+ });
438
+ });
439
+
13
440
  test('allows a verified caller-specific BLE chunk limit', async () => {
14
441
  const getSettingsSpy = jest
15
442
  .spyOn(DataManager, 'getSettings')
@@ -200,7 +627,7 @@ describe('writeProtocolV2File', () => {
200
627
  });
201
628
 
202
629
  expect(logSpy).toHaveBeenLastCalledWith(
203
- '[FileWrite] metrics transport=unknown status=completed path=vol0:/firmware.bin bytes=3/3 elapsed=0.05s speed=0.06 KiB/s hostWriteTotal=0.03s responseWaitTotal=0.03s measuredAttempts=1'
630
+ '[FileWrite] metrics transport=unknown status=completed path=vol0:/firmware.bin bytes=3/3 elapsed=0.05s speed=0.06 KiB/s hostWriteTotal=0.03s responseWaitTotal=0.03s measuredAttempts=1 window=1 chunk=4000 retry=0 averageSpeed=0.06 KiB/s'
204
631
  );
205
632
  } finally {
206
633
  dateNowSpy.mockRestore();
@@ -277,11 +704,11 @@ describe('writeProtocolV2File', () => {
277
704
  );
278
705
  expect(logSpy).toHaveBeenNthCalledWith(
279
706
  2,
280
- '[FileWrite] metrics transport=BLE status=progress path=vol1:/wallpapers/test.bin bytes=3600/5400 elapsed=10.00s speed=0.35 KiB/s'
707
+ '[FileWrite] metrics transport=BLE status=progress path=vol1:/wallpapers/test.bin bytes=3600/5400 elapsed=10.00s speed=0.35 KiB/s window=1 chunk=1800 retry=0 averageSpeed=0.35 KiB/s'
281
708
  );
282
709
  expect(logSpy).toHaveBeenNthCalledWith(
283
710
  3,
284
- '[FileWrite] metrics transport=BLE status=completed path=vol1:/wallpapers/test.bin bytes=5400/5400 elapsed=14.00s speed=0.44 KiB/s'
711
+ '[FileWrite] metrics transport=BLE status=completed path=vol1:/wallpapers/test.bin bytes=5400/5400 elapsed=14.00s speed=0.44 KiB/s window=1 chunk=1800 retry=0 averageSpeed=0.38 KiB/s'
285
712
  );
286
713
  } finally {
287
714
  dateNowSpy.mockRestore();
@@ -315,7 +742,7 @@ describe('writeProtocolV2File', () => {
315
742
  );
316
743
  expect(logSpy).toHaveBeenNthCalledWith(
317
744
  2,
318
- '[FileWrite] metrics transport=unknown status=failed path=vol1:/wallpapers/test.bin bytes=0/4000 elapsed=1.00s speed=0.00 KiB/s'
745
+ '[FileWrite] metrics transport=unknown status=failed path=vol1:/wallpapers/test.bin bytes=0/4000 elapsed=1.00s speed=0.00 KiB/s window=1 chunk=4000 retry=0 averageSpeed=0.00 KiB/s'
319
746
  );
320
747
  } finally {
321
748
  dateNowSpy.mockRestore();
@@ -338,6 +338,42 @@ describe('SearchDevices', () => {
338
338
  }
339
339
  );
340
340
 
341
+ test('BLE discovery returns the advertised color letter without a device identity', async () => {
342
+ mockIsBleConnect.mockReturnValue(true);
343
+
344
+ const descriptor = {
345
+ id: 'ble-peripheral-id',
346
+ path: 'ble-peripheral-id',
347
+ name: 'Neo A1B2',
348
+ commType: 'ble',
349
+ bleColorCode: 'E',
350
+ };
351
+ const method = new SearchDevices({
352
+ id: 1,
353
+ payload: {
354
+ method: 'searchDevices',
355
+ },
356
+ } as any);
357
+ method.init();
358
+ method.connector = {
359
+ enumerate: jest.fn().mockResolvedValue({
360
+ descriptors: [descriptor],
361
+ }),
362
+ } as any;
363
+
364
+ await expect(method.run()).resolves.toEqual([
365
+ {
366
+ ...descriptor,
367
+ connectId: 'ble-peripheral-id',
368
+ serialNo: null,
369
+ uuid: '',
370
+ deviceId: null,
371
+ deviceType: 'neo',
372
+ bleColorCode: 'E',
373
+ },
374
+ ]);
375
+ });
376
+
341
377
  test.each([
342
378
  ['Pro2 A1B2 - Find My', undefined, 'Pro 2 A1B2'],
343
379
  ['Pro2 5E9D - Finde My', undefined, 'Pro 2 5E9D'],