@onekeyfe/hd-core 1.2.2-alpha.104 → 1.2.2-alpha.106

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 (42) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +155 -2
  3. package/__tests__/core-error-output.test.ts +92 -1
  4. package/__tests__/device-lifecycle-events.test.ts +313 -2
  5. package/__tests__/open-wallet-session-error-response.test.ts +2 -2
  6. package/__tests__/open-wallet-session.test.ts +8 -411
  7. package/__tests__/public-device-state-api.test.ts +2 -7
  8. package/dist/api/GetFeatures.d.ts.map +1 -1
  9. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  10. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  11. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +3 -0
  12. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  13. package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
  14. package/dist/core/RequestQueue.d.ts +1 -0
  15. package/dist/core/RequestQueue.d.ts.map +1 -1
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/device/Device.d.ts +2 -0
  19. package/dist/device/Device.d.ts.map +1 -1
  20. package/dist/index.d.ts +17 -19
  21. package/dist/index.js +229 -138
  22. package/dist/types/api/getFeatures.d.ts.map +1 -1
  23. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  24. package/dist/types/api/openWalletSession.d.ts +1 -10
  25. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  26. package/dist/types/params.d.ts.map +1 -1
  27. package/dist/utils/patch.d.ts +1 -1
  28. package/dist/utils/patch.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/api/GetFeatures.ts +1 -0
  31. package/src/api/GetPassphraseState.ts +1 -0
  32. package/src/api/OpenWalletSession.ts +7 -77
  33. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +56 -15
  34. package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
  35. package/src/core/RequestQueue.ts +20 -0
  36. package/src/core/index.ts +95 -42
  37. package/src/data/messages/messages-protocol-v2.json +21 -0
  38. package/src/device/Device.ts +51 -27
  39. package/src/types/api/getFeatures.ts +2 -1
  40. package/src/types/api/getPassphraseState.ts +2 -5
  41. package/src/types/api/openWalletSession.ts +7 -19
  42. package/src/types/params.ts +7 -0
@@ -1,5 +1,6 @@
1
1
  import semver from 'semver';
2
2
  import {
3
+ EDeviceType,
3
4
  ERRORS,
4
5
  HardwareError,
5
6
  HardwareErrorCode,
@@ -15,6 +16,7 @@ import { DEVICE, IFRAME, createUiMessage } from '../../events';
15
16
  import { UI_REQUEST } from '../../constants/ui-request';
16
17
  import { onDeviceButtonHandler } from '../../core';
17
18
  import { runMethodWithUnlockPolicy } from '../../protocols/protocol-v2/unlockPolicyRunner';
19
+ import { supportsProtocolV2Message } from '../../protocols/protocol-v2/features';
18
20
  import {
19
21
  completeRequestContext,
20
22
  createRequestContext,
@@ -22,6 +24,7 @@ import {
22
24
  } from '../../utils/tracing';
23
25
 
24
26
  import type { Device, DeviceEvents } from '../../device/Device';
27
+ import type { DeviceCommands } from '../../device/DeviceCommands';
25
28
  import type { CoreApi } from '../../types';
26
29
  import type {
27
30
  AllNetworkAddress,
@@ -266,6 +269,10 @@ export default abstract class AllNetworkGetAddressBase extends BaseMethod<
266
269
 
267
270
  abortController: AbortController | null = null;
268
271
 
272
+ protected loadingCleanupInBackground = false;
273
+
274
+ private loadingCommands?: DeviceCommands;
275
+
269
276
  init() {
270
277
  this.checkDeviceId = true;
271
278
  this.allowDeviceMode = [...this.allowDeviceMode, UI_REQUEST.NOT_INITIALIZE];
@@ -457,30 +464,64 @@ export default abstract class AllNetworkGetAddressBase extends BaseMethod<
457
464
 
458
465
  abstract getAllNetworkAddress(rootFingerprint: number): Promise<AllNetworkAddress[]>;
459
466
 
467
+ protected async stopAllNetworkLoading(canSend = true) {
468
+ const commands = this.loadingCommands;
469
+ this.loadingCommands = undefined;
470
+ // Never send cleanup through a replacement or disposed connection.
471
+ if (!canSend || !commands || commands.disposed || commands !== this.device.commands) return;
472
+ try {
473
+ await commands.typedCall('DeviceAnimationControl', 'Success', {
474
+ action: PROTO.DeviceAnimationAction.AnimationAction_Stop,
475
+ });
476
+ } catch {
477
+ // Cleanup must not mask the operation result. Firmware also has an idle timeout.
478
+ }
479
+ }
480
+
460
481
  async run() {
461
- const res = await this.device.commands.typedCall('GetPublicKey', 'PublicKey', {
462
- address_n: [toHardened(44), toHardened(1), toHardened(0)],
463
- coin_name: 'Testnet',
464
- script_type: 'SPENDADDRESS',
465
- show_display: false,
466
- });
482
+ this.loadingCleanupInBackground = false;
483
+ try {
484
+ if (this.device.isProtocolV2() && this.device.getCurrentDeviceType() === EDeviceType.Pro2) {
485
+ const protocolInfo = await this.device.ensureProtocolV2RuntimeContext();
486
+ if (supportsProtocolV2Message(protocolInfo, 60461)) {
487
+ const { commands } = this.device;
488
+ await commands.typedCall('DeviceAnimationControl', 'Success', {
489
+ action: PROTO.DeviceAnimationAction.AnimationAction_Start,
490
+ });
491
+ this.loadingCommands = commands;
492
+ }
493
+ }
467
494
 
468
- if (!this.device.isProtocolV2()) {
469
- this.postMessage(createUiMessage(UI_REQUEST.CLOSE_UI_PIN_WINDOW));
470
- }
495
+ const res = await this.device.commands.typedCall('GetPublicKey', 'PublicKey', {
496
+ address_n: [toHardened(44), toHardened(1), toHardened(0)],
497
+ coin_name: 'Testnet',
498
+ script_type: 'SPENDADDRESS',
499
+ show_display: false,
500
+ });
471
501
 
472
- if (res.message.root_fingerprint == null) {
473
- throw ERRORS.TypedError(HardwareErrorCode.CallMethodInvalidParameter);
474
- }
502
+ if (!this.device.isProtocolV2()) {
503
+ this.postMessage(createUiMessage(UI_REQUEST.CLOSE_UI_PIN_WINDOW));
504
+ }
475
505
 
476
- this.abortController = new AbortController();
506
+ if (res.message.root_fingerprint == null) {
507
+ throw ERRORS.TypedError(HardwareErrorCode.CallMethodInvalidParameter);
508
+ }
477
509
 
478
- return this.getAllNetworkAddress(res.message.root_fingerprint).catch(e => {
510
+ this.abortController = new AbortController();
511
+
512
+ return await this.getAllNetworkAddress(res.message.root_fingerprint);
513
+ } catch (e) {
514
+ // A failed call may have invalidated the transport link without disposing
515
+ // DeviceCommands. Let firmware time out instead of reconnecting for Stop.
516
+ await this.stopAllNetworkLoading(false);
479
517
  if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.RepeatUnlocking) {
480
518
  throw ERRORS.TypedError(HardwareErrorCode.RepeatUnlocking, e.message);
481
519
  }
482
520
  throw e;
483
- });
521
+ } finally {
522
+ // The callback API returns before its chain requests finish.
523
+ if (!this.loadingCleanupInBackground) await this.stopAllNetworkLoading();
524
+ }
484
525
  }
485
526
  }
486
527
 
@@ -28,6 +28,7 @@ export default class AllNetworkGetAddressByLoop extends AllNetworkGetAddressBase
28
28
  const bundle = this.payload.bundle || [this.payload];
29
29
 
30
30
  // process callbacks in background
31
+ this.loadingCleanupInBackground = true;
31
32
  const callbackPromise = this.processCallbacksInBackground(
32
33
  bundle,
33
34
  rootFingerprint,
@@ -95,6 +96,7 @@ export default class AllNetworkGetAddressByLoop extends AllNetworkGetAddressBase
95
96
  data: allResults,
96
97
  });
97
98
  } catch (error: any) {
99
+ await this.stopAllNetworkLoading(false);
98
100
  let errorCode = error.errorCode || error.code;
99
101
  let errorMessage = error.message;
100
102
 
@@ -121,6 +123,7 @@ export default class AllNetworkGetAddressByLoop extends AllNetworkGetAddressBase
121
123
  },
122
124
  });
123
125
  } finally {
126
+ await this.stopAllNetworkLoading();
124
127
  this.context?.cancelCallbackTasks(this.payload.connectId);
125
128
  this.abortController = null;
126
129
  }
@@ -1,3 +1,5 @@
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
1
3
  import { LoggerNames, getLogger } from '../utils';
2
4
 
3
5
  import type { Deferred } from '@onekeyfe/hd-shared';
@@ -40,6 +42,24 @@ export default class RequestQueue {
40
42
  return this.requestQueue.get(requestId);
41
43
  }
42
44
 
45
+ public async waitForTask<T>(task: RequestTask, pending: () => Promise<T>): Promise<T> {
46
+ const signal = task.method.abortSignal;
47
+ const cancellationError = () => ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
48
+ if (signal?.aborted) throw cancellationError();
49
+ let onAbort: (() => void) | undefined;
50
+ try {
51
+ const cancelled = new Promise<never>((_, reject) => {
52
+ onAbort = () => reject(cancellationError());
53
+ signal?.addEventListener('abort', onAbort, { once: true });
54
+ });
55
+ const result = await Promise.race([pending(), cancelled]);
56
+ if (signal?.aborted) throw cancellationError();
57
+ return result;
58
+ } finally {
59
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
60
+ }
61
+ }
62
+
43
63
  // 获取请求的AbortController
44
64
  public getAbortController(requestId: number) {
45
65
  return this.requestQueue.get(requestId)?.abortController;
package/src/core/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isProtocolV2LinkDisabledError,
11
11
  } from '@onekeyfe/hd-transport';
12
12
  import {
13
+ EDeviceType,
13
14
  ERRORS,
14
15
  ERROR_CODES_REQUIRE_DISCONNECT,
15
16
  ERROR_CODES_REQUIRE_RELEASE,
@@ -84,6 +85,7 @@ import type { BaseMethod } from '../api/BaseMethod';
84
85
  const Log = getLogger(LoggerNames.Core);
85
86
  const PRE_INITIALIZE_TTL_MS = 60 * 1000;
86
87
  const PRE_PENDING_CALL_TIMEOUT_MS = 15 * 1000;
88
+ const PRO2_USB_SIGNING_COOLDOWN_MS = 1000;
87
89
 
88
90
  // Dedup/coalesce state for "pre-warm signal" methods (isPreWarmSignal),
89
91
  // keyed by getPreWarmKey(): coalesce in-flight, skip if warmed within TTL.
@@ -313,30 +315,44 @@ const waitForPendingPromise = async (
313
315
  if (pendingPromise) {
314
316
  Log.debug('pre pending call promise before call method, wait for it');
315
317
  let timer: ReturnType<typeof setTimeout> | undefined;
316
- let timedOut = false;
317
318
  try {
318
319
  await Promise.race([
319
320
  pendingPromise,
320
- new Promise<void>(resolve => {
321
+ new Promise<void>((_, reject) => {
321
322
  timer = setTimeout(() => {
322
- timedOut = true;
323
- resolve();
323
+ reject(
324
+ ERRORS.TypedError(
325
+ HardwareErrorCode.DeviceBusy,
326
+ 'Previous device cancellation is still draining'
327
+ )
328
+ );
324
329
  }, PRE_PENDING_CALL_TIMEOUT_MS);
325
330
  }),
326
331
  ]);
327
- } catch (error) {
328
- // Cancellation is best-effort; the transport teardown owns recovery.
332
+ // A deadline is not evidence that old I/O is safe to reuse. Keep the
333
+ // barrier on failure; a later call may proceed only after cleanup settles.
334
+ removePrePendingCallPromise?.(connectId, pendingPromise);
329
335
  } finally {
330
336
  if (timer) clearTimeout(timer);
331
- removePrePendingCallPromise?.(connectId, pendingPromise);
332
- }
333
- if (timedOut) {
334
- Log.warn('pre pending call promise timed out before call method', { connectId });
335
337
  }
336
338
  Log.debug('pre pending call promise before call method done');
337
339
  }
338
340
  };
339
341
 
342
+ export function getPostCallPendingPromise(method: BaseMethod, device: Device): Promise<void> {
343
+ const cleanupPromise = device.waitForRunCleanup();
344
+ const env = DataManager.getSettings('env');
345
+ const deviceType = device.getCurrentDeviceType();
346
+ const requiresSigningCooldown =
347
+ method.name.includes('Sign') &&
348
+ (deviceType === EDeviceType.Pro2 || deviceType === EDeviceType.Neo) &&
349
+ (DataManager.isBrowserWebUsb(env) || DataManager.isDesktopWebUsb(env) || env === 'node-usb');
350
+
351
+ return requiresSigningCooldown
352
+ ? cleanupPromise.then(() => wait(PRO2_USB_SIGNING_COOLDOWN_MS)).then(() => undefined)
353
+ : cleanupPromise;
354
+ }
355
+
340
356
  const onCallDevice = async (
341
357
  context: CoreContext,
342
358
  message: CoreMessage,
@@ -367,42 +383,38 @@ const onCallDevice = async (
367
383
  DevicePool.clearDeviceCache(method.payload.connectId);
368
384
  }
369
385
 
370
- // wait for previous callback tasks to complete (ensure device does not call concurrently)
371
- if (method.connectId) {
372
- await context.waitForCallbackTasks(method.connectId);
373
- }
374
-
375
- await waitForPendingPromise(
376
- method.connectId ?? '',
377
- getPrePendingCallPromise,
378
- removePrePendingCallPromise
379
- );
380
-
386
+ // Register before waiting so cancellation also covers queued requests.
381
387
  const task = requestQueue.createTask(method);
382
388
 
383
389
  // Pre-warm holds the device as a per-connectId callback task so a concurrent
384
390
  // real call waits (before ensureConnected) instead of racing its Initialize.
385
391
  // Only covers pre-warm -> real-call ordering; the reverse is fail-closed.
386
392
  let preWarmCallbackTask: Deferred<void> | undefined;
387
- if (method.isPreWarmSignal && method.connectId) {
388
- preWarmCallbackTask = createDeferred<void>();
389
- context.registerCallbackTask(method.connectId, preWarmCallbackTask);
390
- }
391
-
392
393
  let device: Device;
393
394
  try {
395
+ const connectId = method.connectId ?? '';
396
+ if (connectId) {
397
+ await requestQueue.waitForTask(task, () => context.waitForCallbackTasks(connectId));
398
+ }
399
+ await requestQueue.waitForTask(task, () =>
400
+ waitForPendingPromise(
401
+ method.connectId ?? '',
402
+ getPrePendingCallPromise,
403
+ removePrePendingCallPromise
404
+ )
405
+ );
406
+ if (method.isPreWarmSignal && method.connectId) {
407
+ preWarmCallbackTask = createDeferred<void>();
408
+ context.registerCallbackTask(method.connectId, preWarmCallbackTask);
409
+ }
394
410
  /**
395
411
  * Polling to ensure successful connection
396
412
  */
397
- const connectId = method.connectId ?? '';
398
413
  const pollingId = pollingManager.start(connectId);
399
- device = await ensureConnected(
400
- context,
401
- method,
402
- connectId,
403
- pollingId,
404
- task.abortController?.signal
405
- );
414
+ device = await ensureConnected(context, method, connectId, pollingId, method.abortSignal);
415
+ if (method.abortSignal?.aborted) {
416
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
417
+ }
406
418
  } catch (e) {
407
419
  preWarmCallbackTask?.resolve();
408
420
  Log.debug('ensureConnected error: ', e);
@@ -478,14 +490,19 @@ const onCallDevice = async (
478
490
 
479
491
  try {
480
492
  // Wait for any pending task except our own (self-wait would deadlock).
481
- if (method.connectId) {
482
- await context.waitForCallbackTasks(method.connectId, preWarmCallbackTask);
493
+ const { connectId } = method;
494
+ if (connectId) {
495
+ await requestQueue.waitForTask(task, () =>
496
+ context.waitForCallbackTasks(connectId, preWarmCallbackTask)
497
+ );
483
498
  }
484
499
 
485
- await waitForPendingPromise(
486
- method.connectId ?? '',
487
- getPrePendingCallPromise,
488
- removePrePendingCallPromise
500
+ await requestQueue.waitForTask(task, () =>
501
+ waitForPendingPromise(
502
+ method.connectId ?? '',
503
+ getPrePendingCallPromise,
504
+ removePrePendingCallPromise
505
+ )
489
506
  );
490
507
 
491
508
  const inner = async (): Promise<void> => {
@@ -690,6 +707,13 @@ const onCallDevice = async (
690
707
  },
691
708
  });
692
709
  messageResponse = createResponseMessage(method.responseID, true, response);
710
+ // Preserve the acknowledged result while the next call waits for cleanup and cooldown.
711
+ if (method.connectId) {
712
+ context.setPrePendingCallPromise(
713
+ method.connectId,
714
+ getPostCallPendingPromise(method, device)
715
+ );
716
+ }
693
717
  requestQueue.resolveRequest(method.responseID, messageResponse);
694
718
  completeMethodRequestContext(method);
695
719
  } catch (error) {
@@ -701,6 +725,12 @@ const onCallDevice = async (
701
725
  }
702
726
  Log.debug(`Call API - Inner Method Run Error`, error);
703
727
  messageResponse = createResponseMessage(method.responseID, false, { error });
728
+ if (method.connectId) {
729
+ context.setPrePendingCallPromise(
730
+ method.connectId,
731
+ getPostCallPendingPromise(method, device)
732
+ );
733
+ }
704
734
  requestQueue.resolveRequest(method.responseID, messageResponse);
705
735
  completeMethodRequestContext(method, error);
706
736
 
@@ -727,6 +757,9 @@ const onCallDevice = async (
727
757
  skipInitialize: canSkipInitialize(method, device),
728
758
  ...parseInitOptions(method),
729
759
  };
760
+ if (method.abortSignal?.aborted) {
761
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
762
+ }
730
763
  const deviceRun = () => device.run(inner, runOptions);
731
764
  task.callPromise = createDeferred<any>(deviceRun);
732
765
 
@@ -747,6 +780,7 @@ const onCallDevice = async (
747
780
  );
748
781
  Log.debug('Call API - Run Error: ', error);
749
782
  completeMethodRequestContext(method, error);
783
+ return messageResponse;
750
784
  } finally {
751
785
  // Release the pre-warm callback task so the next real call can proceed.
752
786
  preWarmCallbackTask?.resolve();
@@ -1044,7 +1078,7 @@ async function connectDeviceForBle(
1044
1078
  retryCount = 0
1045
1079
  ) {
1046
1080
  try {
1047
- if (device.wasInterruptedByUser()) {
1081
+ if (abortSignal?.aborted || device.wasInterruptedByUser()) {
1048
1082
  throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1049
1083
  }
1050
1084
  if (method.payload.forceProtocolDetection && device.hasDeviceAcquire()) {
@@ -1113,6 +1147,9 @@ async function connectDeviceForBle(
1113
1147
  DevicePool.emitter.emit(DEVICE.CONNECT, device);
1114
1148
  }
1115
1149
  } catch (err) {
1150
+ if (abortSignal?.aborted || device.wasInterruptedByUser()) {
1151
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1152
+ }
1116
1153
  const requiresColdReconnect = isMissingDetectedProtocolV2Error(method, err);
1117
1154
  // Device.run()'s REQUIRE_DISCONNECT handling never sees acquire/initialize
1118
1155
  // failures, so with keep-alive a wedged link would be reused by every retry
@@ -1286,6 +1323,8 @@ const ensureConnected = async (
1286
1323
  HardwareErrorCode.BleDeviceBondedCanceled,
1287
1324
  HardwareErrorCode.BleCharacteristicNotifyError,
1288
1325
  HardwareErrorCode.BleTimeoutError,
1326
+ // A transport setup reset must also stop this outer poll.
1327
+ HardwareErrorCode.PollingTimeout,
1289
1328
  HardwareErrorCode.BleWriteCharacteristicError,
1290
1329
  HardwareErrorCode.BleAlreadyConnected,
1291
1330
  HardwareErrorCode.FirmwareUpdateLimitOneDevice,
@@ -1296,6 +1335,10 @@ const ensureConnected = async (
1296
1335
  HardwareErrorCode.DeviceInterruptedFromUser,
1297
1336
  HardwareErrorCode.CallQueueActionCancelled,
1298
1337
  ].includes(error.errorCode) ||
1338
+ (env === 'react-native' &&
1339
+ [HardwareErrorCode.BleDeviceDisconnected, HardwareErrorCode.PollingTimeout].includes(
1340
+ error.errorCode
1341
+ )) ||
1299
1342
  isTerminalBleStaleBondError(error) ||
1300
1343
  isDeviceIdentityMismatchError(error)
1301
1344
  ) {
@@ -1774,7 +1817,17 @@ export default class Core extends EventEmitter {
1774
1817
  this.prePendingCallPromises.delete(connectId);
1775
1818
  return;
1776
1819
  }
1777
- this.prePendingCallPromises.set(connectId, promise);
1820
+ const previous = this.prePendingCallPromises.get(connectId);
1821
+ const cleanupPromise =
1822
+ previous && previous !== promise
1823
+ ? Promise.all([previous, promise]).then(() => undefined)
1824
+ : promise;
1825
+ this.prePendingCallPromises.set(connectId, cleanupPromise);
1826
+ // cancel() is fire-and-forget. Observe failures immediately, while
1827
+ // preserving the rejected barrier for the next caller's safety check.
1828
+ cleanupPromise.catch(error => {
1829
+ Log.warn('Device cancellation cleanup failed', { errorCode: error?.errorCode });
1830
+ });
1778
1831
  },
1779
1832
  removePrePendingCallPromise: (connectId: string, promise: Promise<void>) => {
1780
1833
  if (this.prePendingCallPromises.get(connectId) === promise) {
@@ -11483,6 +11483,7 @@
11483
11483
  "MessageType_DeviceFindMyTokenState": 60450,
11484
11484
  "MessageType_DeviceFindMyTokenUpdate": 60451,
11485
11485
  "MessageType_DeviceFindMyTokenStateGet": 60452,
11486
+ "MessageType_DeviceAnimationControl": 60461,
11486
11487
  "MessageType_DeviceInfoGet": 60600,
11487
11488
  "MessageType_DeviceInfo": 60601,
11488
11489
  "MessageType_DeviceStatusGet": 60602,
@@ -11806,6 +11807,26 @@
11806
11807
  }
11807
11808
  }
11808
11809
  },
11810
+ "DeviceAnimationAction": {
11811
+ "values": {
11812
+ "AnimationAction_Unknown": 0,
11813
+ "AnimationAction_Start": 1,
11814
+ "AnimationAction_Stop": 2
11815
+ }
11816
+ },
11817
+ "DeviceAnimationControl": {
11818
+ "fields": {
11819
+ "action": {
11820
+ "rule": "required",
11821
+ "type": "DeviceAnimationAction",
11822
+ "id": 1
11823
+ },
11824
+ "timeout_ms": {
11825
+ "type": "uint32",
11826
+ "id": 2
11827
+ }
11828
+ }
11829
+ },
11809
11830
  "DeviceFactoryAck": {
11810
11831
  "values": {
11811
11832
  "FACTORY_ACK_SUCCESS": 0,
@@ -297,6 +297,8 @@ export class Device extends EventEmitter {
297
297
  /** Resolves only after the active run has completed its release path. */
298
298
  private runCleanupPromise?: Promise<void>;
299
299
 
300
+ private userInterruption?: { attempt: number; promise: Promise<void> };
301
+
300
302
  externalState: string[] = [];
301
303
 
302
304
  unavailableCapabilities: UnavailableCapabilities = {};
@@ -1462,6 +1464,10 @@ export class Device extends EventEmitter {
1462
1464
  this.invalidateProtocolV2RuntimeState();
1463
1465
  }
1464
1466
 
1467
+ waitForRunCleanup(): Promise<void> {
1468
+ return this.runCleanupPromise ?? Promise.resolve();
1469
+ }
1470
+
1465
1471
  async run(fn?: () => Promise<void>, options?: RunOptions) {
1466
1472
  if (this.runPromise) {
1467
1473
  await this.interruptionFromOutside();
@@ -1596,45 +1602,63 @@ export class Device extends EventEmitter {
1596
1602
  }
1597
1603
  }
1598
1604
 
1599
- async interruptionFromUser() {
1605
+ interruptionFromUser(): Promise<void> {
1606
+ const attempt = this.connectionAttempt;
1607
+ if (this.userInterruption?.attempt === attempt) {
1608
+ return this.userInterruption.promise;
1609
+ }
1600
1610
  const error = ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1601
- this.interruptedAttempt = this.connectionAttempt;
1611
+ this.interruptedAttempt = attempt;
1602
1612
  const cleanupPromise = this.runCleanupPromise;
1603
- const { cancelableAction } = this;
1604
- if (cancelableAction) {
1605
- await cancelableAction(error);
1606
- } else if (this.shouldSendFallbackProtocolCancel()) {
1607
- await this.commands?.cancelDevice?.().catch(cancelError => {
1608
- Log.debug('Protocol V2 fallback cancel error', cancelError);
1609
- });
1610
- } else if (!this.hasDeviceAcquire()) {
1611
- // Pairing / connect-native / probe: drop the physical link only.
1612
- // Never acquire or send protocol Cancel just to abort setup.
1613
- if (this.mainId && this.deviceConnector?.disconnect) {
1614
- await this.deviceConnector.disconnect(this.mainId).catch(disconnectError => {
1615
- Log.debug('Ignored disconnect during user cancel without acquire', disconnectError);
1616
- });
1613
+ const { cancelableAction, commands, runPromise, deviceConnector, mainId } = this;
1614
+ const sendFallbackCancel = this.shouldSendFallbackProtocolCancel();
1615
+ const acquired = this.hasDeviceAcquire();
1616
+ const promise = (async () => {
1617
+ try {
1618
+ if (cancelableAction) {
1619
+ await cancelableAction(error);
1620
+ } else if (sendFallbackCancel) {
1621
+ await commands?.cancelDevice?.().catch(cancelError => {
1622
+ Log.debug('Protocol V2 fallback cancel error', cancelError);
1623
+ });
1624
+ } else if (!acquired) {
1625
+ // Abort setup without acquiring a session just to send Cancel.
1626
+ if (mainId && deviceConnector?.disconnect) {
1627
+ await deviceConnector.disconnect(mainId);
1628
+ }
1629
+ if (this.connectionAttempt === attempt) this.markTransportDisconnected();
1630
+ }
1631
+ await commands?.cancel();
1632
+ } catch (cleanupError) {
1633
+ Log.warn('User cancellation cleanup failed; disconnecting device', cleanupError);
1634
+ if (mainId && deviceConnector?.disconnect) {
1635
+ await deviceConnector.disconnect(mainId).catch(disconnectError => {
1636
+ Log.debug('User cancellation fallback disconnect failed', disconnectError);
1637
+ });
1638
+ }
1639
+ if (this.connectionAttempt === attempt) this.markTransportDisconnected();
1640
+ } finally {
1641
+ runPromise?.reject(error);
1642
+ if (this.runPromise === runPromise) this.runPromise = null;
1643
+ await cleanupPromise?.catch(() => undefined);
1617
1644
  }
1618
- this.markTransportDisconnected();
1619
- }
1620
- await this.commands?.cancel();
1621
-
1622
- if (this.runPromise) {
1623
- this.runPromise.reject(error);
1624
- this.runPromise = null;
1625
- }
1626
- await cleanupPromise?.catch(() => undefined);
1645
+ })();
1646
+ // Keep the settled promise for this attempt so a duplicate close/finally
1647
+ // cannot send a second wire Cancel. A new attempt gets its own cleanup.
1648
+ this.userInterruption = { attempt, promise };
1649
+ return promise;
1627
1650
  }
1628
1651
 
1629
1652
  setCancelableAction(callback: (err?: Error) => Promise<unknown>) {
1630
- this.cancelableAction = (e?: Error) =>
1653
+ const action = (e?: Error) =>
1631
1654
  callback(e)
1632
1655
  .catch(e2 => {
1633
1656
  Log.debug('cancelableAction error', e2);
1634
1657
  })
1635
1658
  .finally(() => {
1636
- this.clearCancelableAction();
1659
+ if (this.cancelableAction === action) this.clearCancelableAction();
1637
1660
  });
1661
+ this.cancelableAction = action;
1638
1662
  }
1639
1663
 
1640
1664
  clearCancelableAction() {
@@ -2,6 +2,7 @@ import type { CommonParams, Response } from '../params';
2
2
  import type { Features } from '../device';
3
3
 
4
4
  /**
5
- * @deprecated Use `getDeviceState` for both Protocol V1 and Protocol V2 devices.
5
+ * @deprecated Use `getDeviceState` for both Protocol V1 and Protocol V2.
6
+ * Existing calls still work. Read `payload.identity.deviceId` from `getDeviceState`.
6
7
  */
7
8
  export declare function getFeatures(connectId?: string, params?: CommonParams): Response<Features>;
@@ -3,11 +3,8 @@ import type { CommonParams, Response } from '../params';
3
3
  export type GetPassphraseStateParams = CommonParams;
4
4
 
5
5
  /**
6
- * Compatibility wallet-state API for existing App integrations.
7
- *
8
- * Protocol V1 keeps its parameterless firmware flow. Protocol V2 maps the existing
9
- * `useEmptyPassphrase` and `initSession` intent to the device-only wallet-session flow.
10
- * New integrations should prefer `openWalletSession` for explicit wallet intent.
6
+ * @deprecated Use `openWalletSession` for both Protocol V1 and Protocol V2.
7
+ * Existing calls still work. Persist `deviceId + passphraseState`; do not persist firmware `session_id`.
11
8
  */
12
9
  export declare function getPassphraseState(
13
10
  connectId?: string,
@@ -3,28 +3,16 @@ import type { CommonParams, Response } from '../params';
3
3
  export const OpenWalletSessionMode = {
4
4
  Standard: 'standard',
5
5
  SelectHidden: 'select-hidden',
6
- ResumeHidden: 'resume-hidden',
7
6
  } as const;
8
7
 
9
8
  export type OpenWalletSessionModeValue =
10
9
  (typeof OpenWalletSessionMode)[keyof typeof OpenWalletSessionMode];
11
10
 
12
- export type OpenWalletSessionParams =
13
- | {
14
- mode: typeof OpenWalletSessionMode.Standard;
15
- deviceId?: never;
16
- passphraseState?: never;
17
- }
18
- | {
19
- mode: typeof OpenWalletSessionMode.SelectHidden;
20
- deviceId?: never;
21
- passphraseState?: never;
22
- }
23
- | {
24
- mode: typeof OpenWalletSessionMode.ResumeHidden;
25
- deviceId: string;
26
- passphraseState: string;
27
- };
11
+ export type OpenWalletSessionParams = {
12
+ mode: OpenWalletSessionModeValue;
13
+ deviceId?: never;
14
+ passphraseState?: never;
15
+ };
28
16
 
29
17
  type OpenWalletSessionPayloadBase = {
30
18
  protocol: 'V1' | 'V2';
@@ -45,8 +33,8 @@ export type OpenWalletSessionPayload = OpenWalletSessionPayloadBase &
45
33
  );
46
34
 
47
35
  /**
48
- * Opens the standard, hidden, or Attach-to-PIN wallet flow through a unified
49
- * Protocol V1/V2 API.
36
+ * Opens the standard or hidden wallet through a unified Protocol V1/V2 API.
37
+ * Resume a bound hidden wallet by passing passphraseState on later methods.
50
38
  */
51
39
  export declare function openWalletSession(
52
40
  connectId: string,
@@ -1,6 +1,13 @@
1
1
  import type { HardwareConnectProtocol } from '@onekeyfe/hd-shared';
2
2
 
3
3
  export interface CommonParams {
4
+ /**
5
+ * Keep the transport session after this call instead of releasing it.
6
+ * This is a Device.run() hold flag, not a wallet identity. It does not replace
7
+ * `useEmptyPassphrase`, `passphraseState`, or `openWalletSession`.
8
+ * USB multi-step flows and Electron BLE firmware windows still need this to
9
+ * avoid dropping the link between calls.
10
+ */
4
11
  keepSession?: boolean;
5
12
  /**
6
13
  * polling connect max retry count