@onekeyfe/hd-core 1.2.8-alpha.127 → 1.2.8-alpha.129

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 (31) hide show
  1. package/__tests__/device-lifecycle-events.test.ts +96 -0
  2. package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
  3. package/__tests__/protocol-v2-resources.test.ts +14 -0
  4. package/__tests__/protocol-v2.test.ts +306 -14
  5. package/__tests__/protocolV2FileWrite.test.ts +434 -7
  6. package/dist/api/FileWrite.d.ts.map +1 -1
  7. package/dist/api/FirmwareUpdateV4.d.ts +0 -1
  8. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  9. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  10. package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
  11. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  12. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  13. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  14. package/dist/device/Device.d.ts +1 -0
  15. package/dist/device/Device.d.ts.map +1 -1
  16. package/dist/events/ui-request.d.ts +1 -0
  17. package/dist/events/ui-request.d.ts.map +1 -1
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +616 -382
  20. package/dist/protocols/protocol-v2/resources.d.ts +1 -0
  21. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  22. package/package.json +4 -4
  23. package/src/api/FileWrite.ts +7 -1
  24. package/src/api/FirmwareUpdateV4.ts +56 -30
  25. package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
  26. package/src/api/helpers/protocolV2FileWrite.ts +375 -111
  27. package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
  28. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
  29. package/src/device/Device.ts +18 -2
  30. package/src/events/ui-request.ts +1 -0
  31. package/src/protocols/protocol-v2/resources.ts +6 -1
@@ -1,13 +1,20 @@
1
1
  import {
2
2
  PROTOCOL_V2_BLE_FILE_CHUNK_SIZE,
3
+ PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW,
3
4
  PROTOCOL_V2_WEBUSB_FILE_CHUNK_SIZE,
4
5
  } from '@onekeyfe/hd-transport';
5
- import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
6
+ import { EDeviceType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
7
+ import semver from 'semver';
6
8
 
7
9
  import { DataManager } from '../../data-manager';
8
10
  import { LoggerNames, getLogger } from '../../utils/logger';
11
+ import { runMethodWithUnlockPolicy } from '../../protocols/protocol-v2/unlockPolicyRunner';
12
+ import { ProtocolV2UiInteractionCoordinator } from '../../protocols/protocol-v2/uiInteraction';
13
+ import { DevicePool } from '../../device/DevicePool';
14
+ import { DEVICE } from '../../events';
9
15
 
10
16
  import type { DeviceCommands } from '../../device/DeviceCommands';
17
+ import type { BaseMethod } from '../BaseMethod';
11
18
 
12
19
  const Log = getLogger(LoggerNames.Core);
13
20
 
@@ -19,6 +26,7 @@ export type ProtocolV2FileWriteProgress = {
19
26
  totalBytes: number;
20
27
  rateBytesPerSecond?: number;
21
28
  elapsedMs: number;
29
+ retryCount?: number;
22
30
  };
23
31
 
24
32
  export type ProtocolV2FileWriteChunkContext = {
@@ -28,7 +36,7 @@ export type ProtocolV2FileWriteChunkContext = {
28
36
  };
29
37
 
30
38
  export type ProtocolV2FileWriteOptions = {
31
- commands: Pick<DeviceCommands, 'typedCall'>;
39
+ commands: Pick<DeviceCommands, 'typedCall'> & Partial<Pick<DeviceCommands, 'device'>>;
32
40
  path: string;
33
41
  data: ProtocolV2FileWriteData;
34
42
  offset?: number;
@@ -44,6 +52,8 @@ export type ProtocolV2FileWriteOptions = {
44
52
  timeoutMs?: number;
45
53
  writeWithResponse?: boolean;
46
54
  maxChunkRetries?: number;
55
+ /** Reacquire and verify the device before restarting an overwriting asset upload. */
56
+ recoverConnection?: () => Promise<ProtocolV2FileWriteOptions['commands']>;
47
57
  paceMs?: number;
48
58
  throwIfAborted?: () => void;
49
59
  getUiPercentage?: (context: ProtocolV2FileWriteChunkContext) => number | undefined;
@@ -54,6 +64,102 @@ const MIN_FILE_CHUNK_SIZE = 64;
54
64
  const FILE_TRANSFER_RATE_WINDOW_MS = 1000;
55
65
  const FILE_TRANSFER_LOG_INTERVAL_MS = 10_000;
56
66
  const SESSION_ERROR = 'session not found';
67
+ // Application firmware from firmware-pro2 #239 has the 4096-byte UART FIFO required for
68
+ // two 1800-byte chunks. Gate on the running application, not the installed bootloader.
69
+ const ASSET_FILE_WRITE_MIN_APPLICATION_VERSION = '1.0.3';
70
+ const ASSET_FILE_WRITE_RESPONSE_TIMEOUT = 10_000;
71
+
72
+ export function createProtocolV2AssetFileWriteRecovery(
73
+ method: Pick<
74
+ BaseMethod,
75
+ | 'device'
76
+ | 'abortSignal'
77
+ | 'postMessage'
78
+ | 'name'
79
+ | 'unlockPolicy'
80
+ | 'useDevicePassphraseState'
81
+ | 'protocolV2PreUnlockPinType'
82
+ | 'protocolV2UiMode'
83
+ | 'payload'
84
+ >
85
+ ): () => Promise<ProtocolV2FileWriteOptions['commands']> {
86
+ const { device } = method;
87
+ const expectedSerialNo = device.state?.identity?.serialNo;
88
+ const expectedDeviceType = device.state?.identity?.deviceType;
89
+ return async () => {
90
+ if (method.abortSignal?.aborted) {
91
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
92
+ }
93
+ if (!expectedSerialNo) {
94
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckDeviceIdError);
95
+ }
96
+ await device.acquire('V2', { throwOnRunPromiseError: true });
97
+ if (method.abortSignal?.aborted) {
98
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
99
+ }
100
+ await device.initialize();
101
+ if (
102
+ !device.isProtocolV2() ||
103
+ device.state?.identity.serialNo !== expectedSerialNo ||
104
+ device.state?.identity.deviceType !== expectedDeviceType ||
105
+ !['normal', 'notInitialized'].includes(device.state?.status.mode ?? '')
106
+ ) {
107
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckDeviceIdError);
108
+ }
109
+ if (method.abortSignal?.aborted) {
110
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
111
+ }
112
+ // Re-run the existing unlock policy with a fresh preflight after reconnecting.
113
+ await runMethodWithUnlockPolicy(
114
+ {
115
+ name: method.name,
116
+ unlockPolicy: method.unlockPolicy,
117
+ useDevicePassphraseState: method.useDevicePassphraseState,
118
+ protocolV2PreUnlockPinType: method.protocolV2PreUnlockPinType,
119
+ protocolV2UiMode: method.protocolV2UiMode,
120
+ payload: method.payload,
121
+ params: undefined,
122
+ run: () => Promise.resolve(undefined),
123
+ },
124
+ device,
125
+ { uiCoordinator: new ProtocolV2UiInteractionCoordinator(device, method.postMessage) }
126
+ );
127
+ if (method.abortSignal?.aborted) {
128
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
129
+ }
130
+ DevicePool.emitter.emit(DEVICE.CONNECT, device);
131
+ return device.commands;
132
+ };
133
+ }
134
+
135
+ function shouldPipelineAssetFileWrite(options: ProtocolV2FileWriteOptions) {
136
+ const env = DataManager.getSettings('env');
137
+ if (env !== 'react-native' && env !== 'desktop-web-ble') return false;
138
+ if (
139
+ options.overwrite !== true ||
140
+ options.append ||
141
+ (options.offset ?? 0) !== 0 ||
142
+ (options.paceMs ?? 0) > 0 ||
143
+ options.writeWithResponse === true ||
144
+ !/^vol1:\/(?:wallpapers\/wallpaper\.okpkg|nft\/nft-[0-9a-f]{8}-[1-9][0-9]*\.okpkg|portfolio\/portfolio\.okpkg\.pending)$/.test(
145
+ options.path
146
+ )
147
+ ) {
148
+ return false;
149
+ }
150
+ const { device } = options.commands;
151
+ const deviceType = device?.getCurrentDeviceType();
152
+ const firmwareVersion = device?.state?.versions.firmware;
153
+ const mode = device?.state?.status.mode;
154
+ return Boolean(
155
+ device?.isProtocolV2() &&
156
+ (deviceType === EDeviceType.Pro2 || deviceType === EDeviceType.Neo) &&
157
+ (mode === 'normal' || mode === 'notInitialized') &&
158
+ firmwareVersion &&
159
+ semver.valid(firmwareVersion) &&
160
+ semver.gte(firmwareVersion, ASSET_FILE_WRITE_MIN_APPLICATION_VERSION)
161
+ );
162
+ }
57
163
 
58
164
  function formatFileTransferRate(bytesPerSecond: number) {
59
165
  return (Math.max(bytesPerSecond, 0) / 1024).toFixed(2);
@@ -80,6 +186,9 @@ function logFileTransferMetrics({
80
186
  hostWriteElapsedMs,
81
187
  responseWaitElapsedMs,
82
188
  measuredAttempts,
189
+ fileWriteWindow,
190
+ chunkSize,
191
+ retryCount,
83
192
  }: {
84
193
  transport: string;
85
194
  status: 'progress' | 'completed' | 'failed';
@@ -91,6 +200,9 @@ function logFileTransferMetrics({
91
200
  hostWriteElapsedMs: number;
92
201
  responseWaitElapsedMs: number;
93
202
  measuredAttempts: number;
203
+ fileWriteWindow: number;
204
+ chunkSize: number;
205
+ retryCount: number;
94
206
  }) {
95
207
  const segmentedMetrics =
96
208
  measuredAttempts > 0
@@ -101,7 +213,11 @@ function logFileTransferMetrics({
101
213
  Log.log(
102
214
  `[FileWrite] metrics transport=${transport} status=${status} path=${path} bytes=${transferredBytes}/${totalBytes} elapsed=${(
103
215
  elapsedMs / 1000
104
- ).toFixed(2)}s speed=${formatFileTransferRate(rateBytesPerSecond)} KiB/s${segmentedMetrics}`
216
+ ).toFixed(2)}s speed=${formatFileTransferRate(
217
+ rateBytesPerSecond
218
+ )} KiB/s${segmentedMetrics} window=${fileWriteWindow} chunk=${chunkSize} retry=${retryCount} averageSpeed=${formatFileTransferRate(
219
+ getAverageFileTransferRate(transferredBytes, elapsedMs)
220
+ )} KiB/s`
105
221
  );
106
222
  }
107
223
 
@@ -185,16 +301,26 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
185
301
  }
186
302
 
187
303
  const defaultChunkSizeLimit = getProtocolV2FileChunkLimit(options.bleChunkSizeLimit);
304
+ const pipelineAsset = totalSize === dataLength && shouldPipelineAssetFileWrite(options);
188
305
  const configuredChunkSizeLimit = Number(options.chunkSizeLimit);
189
306
  const chunkSizeLimit =
190
307
  Number.isFinite(configuredChunkSizeLimit) && configuredChunkSizeLimit > 0
191
308
  ? Math.floor(configuredChunkSizeLimit)
192
309
  : defaultChunkSizeLimit;
193
- const chunkSize = normalizeChunkSize(options.chunkSize ?? options.chunkLen, chunkSizeLimit);
310
+ const chunkSize = normalizeChunkSize(
311
+ options.chunkSize ?? options.chunkLen,
312
+ pipelineAsset ? Math.min(chunkSizeLimit, PROTOCOL_V2_BLE_FILE_CHUNK_SIZE) : chunkSizeLimit
313
+ );
314
+ const fileWriteWindow =
315
+ pipelineAsset && dataLength > chunkSize ? PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW : 1;
194
316
  let written = 0;
195
317
  let chunks = 0;
196
318
  let lastMessage: Record<string, unknown> | undefined;
197
- const startTime = Date.now();
319
+ let { commands } = options;
320
+ let retryCount = 0;
321
+ const maxChunkRetries = Math.max(Math.floor(options.maxChunkRetries ?? 0), 0);
322
+ const canRecoverPipeline = pipelineAsset && dataLength > chunkSize && options.recoverConnection;
323
+ let startTime = Date.now();
198
324
  let rateWindowStartedAt = startTime;
199
325
  let rateWindowStartedBytes = 0;
200
326
  let rateBytesPerSecond: number | undefined;
@@ -204,32 +330,182 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
204
330
  let hostWriteElapsedMs = 0;
205
331
  let responseWaitElapsedMs = 0;
206
332
  let measuredAttempts = 0;
333
+ const createRequest = (position: number) => {
334
+ const chunk = data.slice(position, Math.min(position + chunkSize, dataLength));
335
+ const offset = startOffset + position;
336
+ const progress =
337
+ options.uiPercentage ??
338
+ options.getUiPercentage?.({
339
+ offset,
340
+ chunkLength: chunk.byteLength,
341
+ totalSize,
342
+ }) ??
343
+ getDeviceTransferProgress(offset, offset + chunk.byteLength, totalSize);
344
+ return {
345
+ file: { path: options.path, offset, total_size: totalSize, data: chunk },
346
+ overwrite: position === 0 ? options.overwrite ?? false : false,
347
+ append: options.append ?? false,
348
+ ui_percentage: progress,
349
+ };
350
+ };
351
+
352
+ const confirmChunk = (
353
+ response: { type?: string; message?: Record<string, unknown> },
354
+ offset: number,
355
+ chunkLength: number
356
+ ) => {
357
+ const responseType = (response as { type?: string }).type;
358
+ if (responseType && responseType !== 'FilesystemFile') {
359
+ const responseError = (response as { message?: { error?: unknown } }).message?.error;
360
+ if (typeof responseError === 'string' && responseError.includes(SESSION_ERROR)) {
361
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, SESSION_ERROR);
362
+ }
363
+ throw ERRORS.TypedError(
364
+ HardwareErrorCode.RuntimeError,
365
+ `FilesystemFileWrite received unexpected response ${responseType}`
366
+ );
367
+ }
368
+ options.throwIfAborted?.();
369
+ lastMessage = response.message;
370
+ const rawProcessedByte = response.message?.processed_byte;
371
+ const processedByte = Number(rawProcessedByte);
372
+ if (
373
+ rawProcessedByte !== undefined &&
374
+ (!Number.isFinite(processedByte) ||
375
+ processedByte <= offset ||
376
+ processedByte > offset + chunkLength)
377
+ ) {
378
+ throw ERRORS.TypedError(
379
+ HardwareErrorCode.RuntimeError,
380
+ `FilesystemFileWrite invalid processed_byte ${processedByte}`
381
+ );
382
+ }
383
+ written = rawProcessedByte === undefined ? written + chunkLength : processedByte - startOffset;
384
+ chunks += 1;
385
+ const now = Date.now();
386
+ lastConfirmedAt = now;
387
+ const elapsedMs = now - startTime;
388
+ const transferredBytes = Math.min(written, dataLength);
389
+ const rateWindowElapsedMs = now - rateWindowStartedAt;
390
+ if (rateWindowElapsedMs >= FILE_TRANSFER_RATE_WINDOW_MS) {
391
+ const rateWindowBytes = Math.max(transferredBytes - rateWindowStartedBytes, 0);
392
+ rateBytesPerSecond = Math.round((rateWindowBytes / rateWindowElapsedMs) * 1000);
393
+ rateWindowStartedAt = now;
394
+ rateWindowStartedBytes = transferredBytes;
395
+ } else if (rateBytesPerSecond === undefined && elapsedMs > 0) {
396
+ rateBytesPerSecond = Math.round((transferredBytes / elapsedMs) * 1000);
397
+ }
398
+ options.onProgress?.({
399
+ progress: getConfirmedProgress(startOffset + written, totalSize, written, dataLength),
400
+ transferredBytes,
401
+ totalBytes: dataLength,
402
+ rateBytesPerSecond,
403
+ elapsedMs,
404
+ ...(retryCount > 0 ? { retryCount } : {}),
405
+ });
406
+ const logWindowElapsedMs = now - logWindowStartedAt;
407
+ if (logWindowElapsedMs >= FILE_TRANSFER_LOG_INTERVAL_MS && transferredBytes < dataLength) {
408
+ const logWindowBytes = Math.max(transferredBytes - logWindowStartedBytes, 0);
409
+ logFileTransferMetrics({
410
+ transport,
411
+ status: 'progress',
412
+ path: options.path,
413
+ transferredBytes,
414
+ totalBytes: dataLength,
415
+ elapsedMs,
416
+ rateBytesPerSecond: getAverageFileTransferRate(logWindowBytes, logWindowElapsedMs),
417
+ hostWriteElapsedMs,
418
+ responseWaitElapsedMs,
419
+ measuredAttempts,
420
+ fileWriteWindow,
421
+ chunkSize,
422
+ retryCount,
423
+ });
424
+ logWindowStartedAt = now;
425
+ logWindowStartedBytes = transferredBytes;
426
+ }
427
+ };
428
+
207
429
  const transport = getFileTransferTransport();
208
430
  Log.log(
209
431
  `[FileWrite] started transport=${transport} path=${options.path} bytes=${dataLength} offset=${startOffset} chunk=${chunkSize}`
210
432
  );
211
433
 
212
- try {
434
+ const writeAttempt = async () => {
435
+ options.throwIfAborted?.();
436
+ const attemptRetryCount = retryCount;
437
+ if (fileWriteWindow === PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW) {
438
+ Log.debug('[FileWrite] BLE asset pipeline', {
439
+ path: options.path,
440
+ window: fileWriteWindow,
441
+ chunkSize,
442
+ retryCount,
443
+ applicationVersion: commands.device?.state?.versions.firmware,
444
+ });
445
+ let sendOffset = chunkSize;
446
+ let interrupted = false;
447
+ let interruption: unknown;
448
+ const response = await commands.typedCall(
449
+ 'FilesystemFileWrite',
450
+ 'FilesystemFile',
451
+ createRequest(0),
452
+ {
453
+ timeoutMs: options.timeoutMs ?? ASSET_FILE_WRITE_RESPONSE_TIMEOUT,
454
+ writeWithResponse: false,
455
+ fileWritePipeline: {
456
+ next: () => {
457
+ if (interrupted || attemptRetryCount !== retryCount || sendOffset >= dataLength)
458
+ return Promise.resolve(undefined);
459
+ try {
460
+ options.throwIfAborted?.();
461
+ const request = createRequest(sendOffset);
462
+ sendOffset += request.file.data.byteLength;
463
+ return Promise.resolve(request);
464
+ } catch (error) {
465
+ interrupted = true;
466
+ interruption = error;
467
+ return Promise.resolve(undefined);
468
+ }
469
+ },
470
+ onResponse: confirmed => {
471
+ if (interrupted || attemptRetryCount !== retryCount) return;
472
+ try {
473
+ const offset = startOffset + written;
474
+ const length = Math.min(chunkSize, dataLength - written);
475
+ if (Number(confirmed.message.processed_byte) !== offset + length) {
476
+ throw ERRORS.TypedError(
477
+ HardwareErrorCode.RuntimeError,
478
+ 'FilesystemFileWrite pipeline did not confirm the complete chunk'
479
+ );
480
+ }
481
+ confirmChunk(confirmed, offset, length);
482
+ } catch (error) {
483
+ // Drain already-sent responses before raising callback errors or cancellation.
484
+ interrupted = true;
485
+ interruption = error;
486
+ }
487
+ },
488
+ },
489
+ }
490
+ );
491
+ options.throwIfAborted?.();
492
+ if (interrupted) throw interruption;
493
+ if (
494
+ response.type !== 'FilesystemFile' ||
495
+ Number(response.message.processed_byte) !== startOffset + dataLength ||
496
+ written !== dataLength
497
+ ) {
498
+ throw ERRORS.TypedError(
499
+ HardwareErrorCode.RuntimeError,
500
+ 'FilesystemFileWrite pipeline ended before the complete file was confirmed'
501
+ );
502
+ }
503
+ }
213
504
  while (written < dataLength) {
214
505
  options.throwIfAborted?.();
215
- const chunk = data.slice(written, Math.min(written + chunkSize, dataLength));
216
- const offset = startOffset + written;
217
- const progress =
218
- options.uiPercentage ??
219
- options.getUiPercentage?.({
220
- offset,
221
- chunkLength: chunk.byteLength,
222
- totalSize,
223
- }) ??
224
- getDeviceTransferProgress(offset, offset + chunk.byteLength, totalSize);
225
- const request = {
226
- file: { path: options.path, offset, total_size: totalSize, data: chunk },
227
- overwrite: chunks === 0 ? options.overwrite ?? false : false,
228
- append: options.append ?? false,
229
- ui_percentage: progress,
230
- };
231
- const maxChunkRetries = Math.max(Math.floor(options.maxChunkRetries ?? 0), 0);
232
- let retryCount = 0;
506
+ const request = createRequest(written);
507
+ const { offset, data: chunk } = request.file;
508
+ let chunkRetryCount = 0;
233
509
  let response;
234
510
  let isWritePending = true;
235
511
  while (isWritePending) {
@@ -239,7 +515,9 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
239
515
  const callOptions =
240
516
  options.writeWithResponse === undefined
241
517
  ? {
242
- timeoutMs: options.timeoutMs,
518
+ timeoutMs:
519
+ options.timeoutMs ??
520
+ (canRecoverPipeline ? ASSET_FILE_WRITE_RESPONSE_TIMEOUT : undefined),
243
521
  onWriteCompleted: ({ elapsedMs }: { elapsedMs: number }) => {
244
522
  currentHostWriteElapsedMs = elapsedMs;
245
523
  responseWaitStartedAt = Date.now();
@@ -253,7 +531,7 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
253
531
  responseWaitStartedAt = Date.now();
254
532
  },
255
533
  };
256
- response = await options.commands.typedCall(
534
+ response = await commands.typedCall(
257
535
  'FilesystemFileWrite',
258
536
  'FilesystemFile',
259
537
  request,
@@ -261,8 +539,13 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
261
539
  );
262
540
  isWritePending = false;
263
541
  } catch (error) {
264
- if (retryCount >= maxChunkRetries || !isProtocolV2ResponseTimeout(error)) throw error;
265
- retryCount += 1;
542
+ if (
543
+ canRecoverPipeline ||
544
+ chunkRetryCount >= maxChunkRetries ||
545
+ !isProtocolV2ResponseTimeout(error)
546
+ )
547
+ throw error;
548
+ chunkRetryCount += 1;
266
549
  options.throwIfAborted?.();
267
550
  } finally {
268
551
  if (currentHostWriteElapsedMs !== undefined && responseWaitStartedAt !== undefined) {
@@ -278,97 +561,75 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
278
561
  'FilesystemFileWrite completed without a response'
279
562
  );
280
563
  }
281
- const responseType = (response as { type?: string }).type;
282
- if (responseType && responseType !== 'FilesystemFile') {
283
- const responseError = (response as { message?: { error?: unknown } }).message?.error;
284
- if (typeof responseError === 'string' && responseError.includes(SESSION_ERROR)) {
285
- throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, SESSION_ERROR);
286
- }
287
- throw ERRORS.TypedError(
288
- HardwareErrorCode.RuntimeError,
289
- `FilesystemFileWrite received unexpected response ${responseType}`
290
- );
564
+ confirmChunk(response, offset, chunk.byteLength);
565
+ if (options.paceMs && options.paceMs > 0) {
566
+ await new Promise(resolve => {
567
+ setTimeout(resolve, options.paceMs);
568
+ });
291
569
  }
292
- options.throwIfAborted?.();
293
- lastMessage = response.message;
294
- const rawProcessedByte = response.message?.processed_byte;
295
- const processedByte = Number(rawProcessedByte);
570
+ }
571
+ };
572
+ while (true) {
573
+ try {
574
+ await writeAttempt();
575
+ break;
576
+ } catch (error) {
577
+ const now = Date.now();
578
+ const elapsedMs = Math.max(now - startTime, 0);
579
+ const logWindowElapsedMs = Math.max(now - logWindowStartedAt, 0);
580
+ const logWindowBytes = Math.max(written - logWindowStartedBytes, 0);
581
+ logFileTransferMetrics({
582
+ transport,
583
+ status: 'failed',
584
+ path: options.path,
585
+ transferredBytes: written,
586
+ totalBytes: dataLength,
587
+ elapsedMs,
588
+ rateBytesPerSecond: getAverageFileTransferRate(logWindowBytes, logWindowElapsedMs),
589
+ hostWriteElapsedMs,
590
+ responseWaitElapsedMs,
591
+ measuredAttempts,
592
+ fileWriteWindow,
593
+ chunkSize,
594
+ retryCount,
595
+ });
296
596
  if (
297
- rawProcessedByte !== undefined &&
298
- (!Number.isFinite(processedByte) ||
299
- processedByte <= offset ||
300
- processedByte > offset + chunk.byteLength)
597
+ !canRecoverPipeline ||
598
+ retryCount >= maxChunkRetries ||
599
+ !isProtocolV2ResponseTimeout(error)
301
600
  ) {
302
- throw ERRORS.TypedError(
303
- HardwareErrorCode.RuntimeError,
304
- `FilesystemFileWrite invalid processed_byte ${processedByte}`
305
- );
306
- }
307
- written =
308
- rawProcessedByte === undefined ? written + chunk.byteLength : processedByte - startOffset;
309
- chunks += 1;
310
- const now = Date.now();
311
- lastConfirmedAt = now;
312
- const elapsedMs = now - startTime;
313
- const transferredBytes = Math.min(written, dataLength);
314
- const rateWindowElapsedMs = now - rateWindowStartedAt;
315
- if (rateWindowElapsedMs >= FILE_TRANSFER_RATE_WINDOW_MS) {
316
- const rateWindowBytes = Math.max(transferredBytes - rateWindowStartedBytes, 0);
317
- rateBytesPerSecond = Math.round((rateWindowBytes / rateWindowElapsedMs) * 1000);
318
- rateWindowStartedAt = now;
319
- rateWindowStartedBytes = transferredBytes;
320
- } else if (rateBytesPerSecond === undefined && elapsedMs > 0) {
321
- rateBytesPerSecond = Math.round((transferredBytes / elapsedMs) * 1000);
601
+ throw error;
322
602
  }
603
+ options.throwIfAborted?.();
604
+ retryCount += 1;
605
+ Log.log(
606
+ `[FileWrite] retry=${retryCount}/${maxChunkRetries} restarting window=${fileWriteWindow} after reconnect path=${options.path}`
607
+ );
323
608
  options.onProgress?.({
324
- progress: getConfirmedProgress(startOffset + written, totalSize, written, dataLength),
325
- transferredBytes,
609
+ progress: 0,
610
+ transferredBytes: 0,
326
611
  totalBytes: dataLength,
327
- rateBytesPerSecond,
328
- elapsedMs,
612
+ elapsedMs: 0,
613
+ retryCount,
329
614
  });
330
- const logWindowElapsedMs = now - logWindowStartedAt;
331
- if (logWindowElapsedMs >= FILE_TRANSFER_LOG_INTERVAL_MS && transferredBytes < dataLength) {
332
- const logWindowBytes = Math.max(transferredBytes - logWindowStartedBytes, 0);
333
- logFileTransferMetrics({
334
- transport,
335
- status: 'progress',
336
- path: options.path,
337
- transferredBytes,
338
- totalBytes: dataLength,
339
- elapsedMs,
340
- rateBytesPerSecond: getAverageFileTransferRate(logWindowBytes, logWindowElapsedMs),
341
- hostWriteElapsedMs,
342
- responseWaitElapsedMs,
343
- measuredAttempts,
344
- });
345
- logWindowStartedAt = now;
346
- logWindowStartedBytes = transferredBytes;
347
- }
348
- if (options.paceMs && options.paceMs > 0) {
349
- await new Promise(resolve => {
350
- setTimeout(resolve, options.paceMs);
351
- });
352
- }
615
+ // The timed-out link is invalid. Only a verified reconnect may restart this
616
+ // fixed-path overwriting upload; never replay an uncertain in-flight chunk.
617
+ commands = await canRecoverPipeline();
618
+ options.throwIfAborted?.();
619
+ written = 0;
620
+ chunks = 0;
621
+ lastMessage = undefined;
622
+ startTime = Date.now();
623
+ lastConfirmedAt = startTime;
624
+ rateWindowStartedAt = startTime;
625
+ rateWindowStartedBytes = 0;
626
+ rateBytesPerSecond = undefined;
627
+ logWindowStartedAt = startTime;
628
+ logWindowStartedBytes = 0;
629
+ hostWriteElapsedMs = 0;
630
+ responseWaitElapsedMs = 0;
631
+ measuredAttempts = 0;
353
632
  }
354
- } catch (error) {
355
- const now = Date.now();
356
- const elapsedMs = Math.max(now - startTime, 0);
357
- const logWindowElapsedMs = Math.max(now - logWindowStartedAt, 0);
358
- const logWindowBytes = Math.max(written - logWindowStartedBytes, 0);
359
- logFileTransferMetrics({
360
- transport,
361
- status: 'failed',
362
- path: options.path,
363
- transferredBytes: written,
364
- totalBytes: dataLength,
365
- elapsedMs,
366
- rateBytesPerSecond: getAverageFileTransferRate(logWindowBytes, logWindowElapsedMs),
367
- hostWriteElapsedMs,
368
- responseWaitElapsedMs,
369
- measuredAttempts,
370
- });
371
- throw error;
372
633
  }
373
634
 
374
635
  const elapsedMs = Math.max(lastConfirmedAt - startTime, 0);
@@ -385,6 +646,9 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) {
385
646
  hostWriteElapsedMs,
386
647
  responseWaitElapsedMs,
387
648
  measuredAttempts,
649
+ fileWriteWindow,
650
+ chunkSize,
651
+ retryCount,
388
652
  });
389
653
 
390
654
  return {
@@ -27,7 +27,10 @@ import {
27
27
  import { BaseMethod } from '../BaseMethod';
28
28
  import { decodeJpegBase64ToRgba } from '../helpers/base64Data';
29
29
  import { invalidParameter } from '../helpers/filesystemValidation';
30
- import { writeProtocolV2File } from '../helpers/protocolV2FileWrite';
30
+ import {
31
+ createProtocolV2AssetFileWriteRecovery,
32
+ writeProtocolV2File,
33
+ } from '../helpers/protocolV2FileWrite';
31
34
 
32
35
  export type DeviceUploadNftParams = {
33
36
  imageJpegBase64: string;
@@ -238,6 +241,8 @@ export default class DeviceUploadNft extends BaseMethod<DeviceUploadNftParams> {
238
241
  chunkSize: this.params.chunkSize,
239
242
  timeoutMs: this.params.timeoutMs,
240
243
  paceMs: this.params.paceMs,
244
+ maxChunkRetries: 3,
245
+ recoverConnection: createProtocolV2AssetFileWriteRecovery(this),
241
246
  overwrite: true,
242
247
  append: false,
243
248
  throwIfAborted: () => this.throwIfAborted(),
@@ -6,7 +6,11 @@ import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
6
6
  import { BaseMethod } from '../BaseMethod';
7
7
  import { decodeJpegBase64ToRgba } from '../helpers/base64Data';
8
8
  import { invalidParameter } from '../helpers/filesystemValidation';
9
- import { writeProtocolV2File } from '../helpers/protocolV2FileWrite';
9
+ import {
10
+ createProtocolV2AssetFileWriteRecovery,
11
+ writeProtocolV2File,
12
+ } from '../helpers/protocolV2FileWrite';
13
+ import { ProtocolV2UiInteractionCoordinator } from '../../protocols/protocol-v2/uiInteraction';
10
14
  import { UI_REQUEST, createUiMessage } from '../../events/ui-request';
11
15
  import { supportsProtocolV2Message } from '../../protocols/protocol-v2/features';
12
16
  import { LoggerNames, getLogger } from '../../utils';
@@ -142,6 +146,7 @@ export default class DeviceUploadWallpaper extends BaseMethod<DeviceUploadWallpa
142
146
  chunkSize: this.params.chunkSize,
143
147
  bleChunkSizeLimit,
144
148
  maxChunkRetries: 3,
149
+ recoverConnection: createProtocolV2AssetFileWriteRecovery(this),
145
150
  overwrite: true,
146
151
  append: false,
147
152
  throwIfAborted: () => this.throwIfAborted(),
@@ -171,6 +176,15 @@ export default class DeviceUploadWallpaper extends BaseMethod<DeviceUploadWallpa
171
176
  data,
172
177
  useHostAssetPackage ? WALLPAPER_PACKAGE_BLE_CHUNK_SIZE : undefined
173
178
  );
179
+ this.throwIfAborted();
180
+ new ProtocolV2UiInteractionCoordinator(this.device, this.postMessage).enterMethodInteraction({
181
+ request: 'button',
182
+ source: 'method-lifecycle',
183
+ reason: 'device-management',
184
+ completion: 'operation-completed',
185
+ deviceOnly: true,
186
+ operation: this.name,
187
+ });
174
188
  const response = await this.device.commands.typedCall('DeviceSettingsSet', 'Success', {
175
189
  settings: { wallpaper_path: this.path },
176
190
  });