@onekeyfe/hd-core 1.2.3-alpha.1 → 1.2.3-alpha.11

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 (58) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +503 -10
  3. package/__tests__/core-error-output.test.ts +169 -1
  4. package/__tests__/device-lifecycle-events.test.ts +350 -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__/protocol-v2.test.ts +51 -0
  8. package/__tests__/public-device-state-api.test.ts +2 -7
  9. package/__tests__/search-devices.test.ts +196 -8
  10. package/__tests__/sol-sign-offchain-message.test.ts +0 -8
  11. package/dist/api/GetFeatures.d.ts.map +1 -1
  12. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  13. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  14. package/dist/api/SearchDevices.d.ts +2 -15
  15. package/dist/api/SearchDevices.d.ts.map +1 -1
  16. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts +2 -0
  17. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts.map +1 -1
  18. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +7 -1
  19. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  20. package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
  21. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  22. package/dist/core/RequestQueue.d.ts +1 -0
  23. package/dist/core/RequestQueue.d.ts.map +1 -1
  24. package/dist/core/index.d.ts +1 -0
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/data-manager/TransportManager.d.ts +2 -0
  27. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  28. package/dist/device/Device.d.ts +2 -0
  29. package/dist/device/Device.d.ts.map +1 -1
  30. package/dist/index.d.ts +17 -19
  31. package/dist/index.js +449 -184
  32. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  33. package/dist/types/api/getFeatures.d.ts.map +1 -1
  34. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  35. package/dist/types/api/openWalletSession.d.ts +1 -10
  36. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  37. package/dist/types/params.d.ts.map +1 -1
  38. package/dist/utils/patch.d.ts +1 -1
  39. package/dist/utils/patch.d.ts.map +1 -1
  40. package/package.json +4 -4
  41. package/src/api/GetFeatures.ts +1 -0
  42. package/src/api/GetPassphraseState.ts +1 -0
  43. package/src/api/OpenWalletSession.ts +7 -77
  44. package/src/api/SearchDevices.ts +121 -27
  45. package/src/api/allnetwork/AllNetworkGetAddress.ts +79 -45
  46. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +95 -24
  47. package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
  48. package/src/api/device/DeviceVerify.ts +8 -0
  49. package/src/core/RequestQueue.ts +20 -0
  50. package/src/core/index.ts +135 -43
  51. package/src/data/messages/messages-protocol-v2.json +21 -0
  52. package/src/data-manager/TransportManager.ts +14 -3
  53. package/src/device/Device.ts +58 -27
  54. package/src/protocols/protocol-v2/walletSession.ts +7 -0
  55. package/src/types/api/getFeatures.ts +2 -1
  56. package/src/types/api/getPassphraseState.ts +2 -5
  57. package/src/types/api/openWalletSession.ts +7 -19
  58. package/src/types/params.ts +7 -0
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,
@@ -83,7 +84,8 @@ import type { BaseMethod } from '../api/BaseMethod';
83
84
 
84
85
  const Log = getLogger(LoggerNames.Core);
85
86
  const PRE_INITIALIZE_TTL_MS = 60 * 1000;
86
- const PRE_PENDING_CALL_TIMEOUT_MS = 15 * 1000;
87
+ const PRE_PENDING_CALL_TIMEOUT_MS = 5 * 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.
@@ -181,6 +183,7 @@ export const callAPI = async (context: CoreContext, message: CoreMessage) => {
181
183
  }
182
184
  };
183
185
  method.setContext?.(context);
186
+ method.context = context;
184
187
 
185
188
  method.requestContext = createRequestContext(method.responseID, method.name, {
186
189
  sdkInstanceId: context.sdkInstanceId,
@@ -201,7 +204,12 @@ export const callAPI = async (context: CoreContext, message: CoreMessage) => {
201
204
  if (!method.useDevice) {
202
205
  updateMethodRequestContext(method, { status: 'running' });
203
206
  try {
204
- const response = await method.run();
207
+ const env = DataManager.getSettings('env');
208
+ const response =
209
+ method.name === 'searchDevices' &&
210
+ (DataManager.isBrowserWebUsb(env) || DataManager.isDesktopWebUsb(env))
211
+ ? await context.methodSynchronize(() => method.run(), 'webusb-discovery')
212
+ : await method.run();
205
213
  completeMethodRequestContext(method);
206
214
  return createResponseMessage(method.responseID, true, response);
207
215
  } catch (error) {
@@ -314,6 +322,7 @@ const waitForPendingPromise = async (
314
322
  Log.debug('pre pending call promise before call method, wait for it');
315
323
  let timer: ReturnType<typeof setTimeout> | undefined;
316
324
  let timedOut = false;
325
+ let completed = false;
317
326
  try {
318
327
  await Promise.race([
319
328
  pendingPromise,
@@ -324,11 +333,12 @@ const waitForPendingPromise = async (
324
333
  }, PRE_PENDING_CALL_TIMEOUT_MS);
325
334
  }),
326
335
  ]);
327
- } catch (error) {
328
- // Cancellation is best-effort; the transport teardown owns recovery.
336
+ completed = !timedOut;
329
337
  } finally {
330
338
  if (timer) clearTimeout(timer);
331
- removePrePendingCallPromise?.(connectId, pendingPromise);
339
+ // Keep a rejected cleanup barrier for the next caller's safety check;
340
+ // only a completed cleanup or a timeout may clear it.
341
+ if (timedOut || completed) removePrePendingCallPromise?.(connectId, pendingPromise);
332
342
  }
333
343
  if (timedOut) {
334
344
  Log.warn('pre pending call promise timed out before call method', { connectId });
@@ -337,6 +347,20 @@ const waitForPendingPromise = async (
337
347
  }
338
348
  };
339
349
 
350
+ export function getPostCallPendingPromise(method: BaseMethod, device: Device): Promise<void> {
351
+ const cleanupPromise = device.waitForRunCleanup();
352
+ const env = DataManager.getSettings('env');
353
+ const deviceType = device.getCurrentDeviceType();
354
+ const requiresSigningCooldown =
355
+ method.name.includes('Sign') &&
356
+ (deviceType === EDeviceType.Pro2 || deviceType === EDeviceType.Neo) &&
357
+ (DataManager.isBrowserWebUsb(env) || DataManager.isDesktopWebUsb(env) || env === 'node-usb');
358
+
359
+ return requiresSigningCooldown
360
+ ? cleanupPromise.then(() => wait(PRO2_USB_SIGNING_COOLDOWN_MS)).then(() => undefined)
361
+ : cleanupPromise;
362
+ }
363
+
340
364
  const onCallDevice = async (
341
365
  context: CoreContext,
342
366
  message: CoreMessage,
@@ -367,42 +391,57 @@ const onCallDevice = async (
367
391
  DevicePool.clearDeviceCache(method.payload.connectId);
368
392
  }
369
393
 
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
-
394
+ // Register before waiting so cancellation also covers queued requests.
381
395
  const task = requestQueue.createTask(method);
382
396
 
383
397
  // Pre-warm holds the device as a per-connectId callback task so a concurrent
384
398
  // real call waits (before ensureConnected) instead of racing its Initialize.
385
399
  // Only covers pre-warm -> real-call ordering; the reverse is fail-closed.
386
400
  let preWarmCallbackTask: Deferred<void> | undefined;
387
- if (method.isPreWarmSignal && method.connectId) {
388
- preWarmCallbackTask = createDeferred<void>();
389
- context.registerCallbackTask(method.connectId, preWarmCallbackTask);
390
- }
391
-
392
401
  let device: Device;
393
402
  try {
403
+ const connectId = method.connectId ?? '';
404
+ if (connectId) {
405
+ await requestQueue.waitForTask(task, () => context.waitForCallbackTasks(connectId));
406
+ }
407
+ await requestQueue.waitForTask(task, () =>
408
+ waitForPendingPromise(
409
+ method.connectId ?? '',
410
+ getPrePendingCallPromise,
411
+ removePrePendingCallPromise
412
+ )
413
+ );
414
+ if (method.isPreWarmSignal && method.connectId) {
415
+ preWarmCallbackTask = createDeferred<void>();
416
+ context.registerCallbackTask(method.connectId, preWarmCallbackTask);
417
+ }
394
418
  /**
395
419
  * Polling to ensure successful connection
396
420
  */
397
- const connectId = method.connectId ?? '';
398
421
  const pollingId = pollingManager.start(connectId);
399
- device = await ensureConnected(
400
- context,
401
- method,
402
- connectId,
403
- pollingId,
404
- task.abortController?.signal
405
- );
422
+ const env = DataManager.getSettings('env');
423
+ const connect = () =>
424
+ ensureConnected(context, method, connectId, pollingId, method.abortSignal);
425
+ // Discovery may acquire USB endpoints. Finish it before initializing a public
426
+ // request; once registered, that request makes discovery use cached state only.
427
+ if (DataManager.isBrowserWebUsb(env) || DataManager.isDesktopWebUsb(env)) {
428
+ // Synchronization can keep the connect action queued after the caller is
429
+ // cancelled. Observe that promise so a late device-not-found error does
430
+ // not become an unhandled rejection.
431
+ const connectPromise = context.methodSynchronize(async () => {
432
+ if (method.abortSignal?.aborted) {
433
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
434
+ }
435
+ return connect();
436
+ }, 'webusb-discovery');
437
+ connectPromise.catch(() => undefined);
438
+ device = await requestQueue.waitForTask(task, () => connectPromise);
439
+ } else {
440
+ device = await connect();
441
+ }
442
+ if (method.abortSignal?.aborted) {
443
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
444
+ }
406
445
  } catch (e) {
407
446
  preWarmCallbackTask?.resolve();
408
447
  Log.debug('ensureConnected error: ', e);
@@ -478,14 +517,19 @@ const onCallDevice = async (
478
517
 
479
518
  try {
480
519
  // Wait for any pending task except our own (self-wait would deadlock).
481
- if (method.connectId) {
482
- await context.waitForCallbackTasks(method.connectId, preWarmCallbackTask);
520
+ const { connectId } = method;
521
+ if (connectId) {
522
+ await requestQueue.waitForTask(task, () =>
523
+ context.waitForCallbackTasks(connectId, preWarmCallbackTask)
524
+ );
483
525
  }
484
526
 
485
- await waitForPendingPromise(
486
- method.connectId ?? '',
487
- getPrePendingCallPromise,
488
- removePrePendingCallPromise
527
+ await requestQueue.waitForTask(task, () =>
528
+ waitForPendingPromise(
529
+ method.connectId ?? '',
530
+ getPrePendingCallPromise,
531
+ removePrePendingCallPromise
532
+ )
489
533
  );
490
534
 
491
535
  const inner = async (): Promise<void> => {
@@ -690,6 +734,13 @@ const onCallDevice = async (
690
734
  },
691
735
  });
692
736
  messageResponse = createResponseMessage(method.responseID, true, response);
737
+ // Preserve the acknowledged result while the next call waits for cleanup and cooldown.
738
+ if (method.connectId) {
739
+ context.setPrePendingCallPromise(
740
+ method.connectId,
741
+ getPostCallPendingPromise(method, device)
742
+ );
743
+ }
693
744
  requestQueue.resolveRequest(method.responseID, messageResponse);
694
745
  completeMethodRequestContext(method);
695
746
  } catch (error) {
@@ -701,6 +752,12 @@ const onCallDevice = async (
701
752
  }
702
753
  Log.debug(`Call API - Inner Method Run Error`, error);
703
754
  messageResponse = createResponseMessage(method.responseID, false, { error });
755
+ if (method.connectId) {
756
+ context.setPrePendingCallPromise(
757
+ method.connectId,
758
+ getPostCallPendingPromise(method, device)
759
+ );
760
+ }
704
761
  requestQueue.resolveRequest(method.responseID, messageResponse);
705
762
  completeMethodRequestContext(method, error);
706
763
 
@@ -727,6 +784,9 @@ const onCallDevice = async (
727
784
  skipInitialize: canSkipInitialize(method, device),
728
785
  ...parseInitOptions(method),
729
786
  };
787
+ if (method.abortSignal?.aborted) {
788
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
789
+ }
730
790
  const deviceRun = () => device.run(inner, runOptions);
731
791
  task.callPromise = createDeferred<any>(deviceRun);
732
792
 
@@ -747,6 +807,7 @@ const onCallDevice = async (
747
807
  );
748
808
  Log.debug('Call API - Run Error: ', error);
749
809
  completeMethodRequestContext(method, error);
810
+ return messageResponse;
750
811
  } finally {
751
812
  // Release the pre-warm callback task so the next real call can proceed.
752
813
  preWarmCallbackTask?.resolve();
@@ -1044,7 +1105,7 @@ async function connectDeviceForBle(
1044
1105
  retryCount = 0
1045
1106
  ) {
1046
1107
  try {
1047
- if (device.wasInterruptedByUser()) {
1108
+ if (abortSignal?.aborted || device.wasInterruptedByUser()) {
1048
1109
  throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1049
1110
  }
1050
1111
  if (method.payload.forceProtocolDetection && device.hasDeviceAcquire()) {
@@ -1113,6 +1174,9 @@ async function connectDeviceForBle(
1113
1174
  DevicePool.emitter.emit(DEVICE.CONNECT, device);
1114
1175
  }
1115
1176
  } catch (err) {
1177
+ if (abortSignal?.aborted || device.wasInterruptedByUser()) {
1178
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1179
+ }
1116
1180
  const requiresColdReconnect = isMissingDetectedProtocolV2Error(method, err);
1117
1181
  // Device.run()'s REQUIRE_DISCONNECT handling never sees acquire/initialize
1118
1182
  // failures, so with keep-alive a wedged link would be reused by every retry
@@ -1163,6 +1227,7 @@ const ensureConnected = async (
1163
1227
  const POLL_INTERVAL_TIME = (method.payload && method.payload.pollIntervalTime) || 1000;
1164
1228
  const TIME_OUT = (method.payload && method.payload.timeout) || 10000;
1165
1229
  let timer: ReturnType<typeof setTimeout> | null = null;
1230
+ let lastInitializeError: any;
1166
1231
  Log.debug(
1167
1232
  `EnsureConnected function start, MAX_RETRY_COUNT=${MAX_RETRY_COUNT}, POLL_INTERVAL_TIME=${POLL_INTERVAL_TIME} `
1168
1233
  );
@@ -1203,7 +1268,9 @@ const ensureConnected = async (
1203
1268
  Log.debug('EnsureConnected function try count: ', tryCount, ' poll interval time: ', time);
1204
1269
  try {
1205
1270
  await initDeviceList(method);
1271
+ lastInitializeError = undefined;
1206
1272
  } catch (error) {
1273
+ lastInitializeError = error;
1207
1274
  Log.debug('device list error: ', error);
1208
1275
  if (
1209
1276
  [
@@ -1218,6 +1285,7 @@ const ensureConnected = async (
1218
1285
  }
1219
1286
  if (error.errorCode === HardwareErrorCode.TransportNotConfigured) {
1220
1287
  await TransportManager.configure();
1288
+ lastInitializeError = undefined;
1221
1289
  }
1222
1290
  }
1223
1291
 
@@ -1286,6 +1354,8 @@ const ensureConnected = async (
1286
1354
  HardwareErrorCode.BleDeviceBondedCanceled,
1287
1355
  HardwareErrorCode.BleCharacteristicNotifyError,
1288
1356
  HardwareErrorCode.BleTimeoutError,
1357
+ // A transport setup reset must also stop this outer poll.
1358
+ HardwareErrorCode.PollingTimeout,
1289
1359
  HardwareErrorCode.BleWriteCharacteristicError,
1290
1360
  HardwareErrorCode.BleAlreadyConnected,
1291
1361
  HardwareErrorCode.FirmwareUpdateLimitOneDevice,
@@ -1296,6 +1366,10 @@ const ensureConnected = async (
1296
1366
  HardwareErrorCode.DeviceInterruptedFromUser,
1297
1367
  HardwareErrorCode.CallQueueActionCancelled,
1298
1368
  ].includes(error.errorCode) ||
1369
+ (env === 'react-native' &&
1370
+ [HardwareErrorCode.BleDeviceDisconnected, HardwareErrorCode.PollingTimeout].includes(
1371
+ error.errorCode
1372
+ )) ||
1299
1373
  isTerminalBleStaleBondError(error) ||
1300
1374
  isDeviceIdentityMismatchError(error)
1301
1375
  ) {
@@ -1309,14 +1383,22 @@ const ensureConnected = async (
1309
1383
  clearTimeout(timer);
1310
1384
  }
1311
1385
  Log.debug('EnsureConnected get to max try count, will return: ', tryCount);
1312
- // Browser WebUSB needs permission prompt, desktop WebUSB doesn't
1313
- // skipWebDevicePrompt can override this behavior for special cases
1314
- if (DataManager.isBrowserWebUsb(env) && !method.payload?.skipWebDevicePrompt) {
1386
+ const preserveWebUsbInitError =
1387
+ DataManager.isBrowserWebUsb(env) || DataManager.isDesktopWebUsb(env);
1388
+ const needsPermissionPrompt =
1389
+ DataManager.isBrowserWebUsb(env) && !method.payload?.skipWebDevicePrompt;
1390
+ const fallbackError = needsPermissionPrompt
1391
+ ? ERRORS.TypedError(HardwareErrorCode.WebDeviceNotFoundOrNeedsPermission)
1392
+ : ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
1393
+ const errorToReject =
1394
+ preserveWebUsbInitError && lastInitializeError ? lastInitializeError : fallbackError;
1395
+ // Only ask the host for a WebUSB grant when the failure is actually
1396
+ // "not found / needs permission". A preserved initialize error must
1397
+ // not fire that prompt with a different public code.
1398
+ if (needsPermissionPrompt && errorToReject === fallbackError) {
1315
1399
  postMessage(createUiMessage(UI_REQUEST.WEB_DEVICE_PROMPT_ACCESS_PERMISSION));
1316
- reject(ERRORS.TypedError(HardwareErrorCode.WebDeviceNotFoundOrNeedsPermission));
1317
- } else {
1318
- reject(ERRORS.TypedError(HardwareErrorCode.DeviceNotFound));
1319
1400
  }
1401
+ reject(errorToReject);
1320
1402
  return;
1321
1403
  }
1322
1404
 
@@ -1774,7 +1856,17 @@ export default class Core extends EventEmitter {
1774
1856
  this.prePendingCallPromises.delete(connectId);
1775
1857
  return;
1776
1858
  }
1777
- this.prePendingCallPromises.set(connectId, promise);
1859
+ const previous = this.prePendingCallPromises.get(connectId);
1860
+ const cleanupPromise =
1861
+ previous && previous !== promise
1862
+ ? Promise.all([previous, promise]).then(() => undefined)
1863
+ : promise;
1864
+ this.prePendingCallPromises.set(connectId, cleanupPromise);
1865
+ // cancel() is fire-and-forget. Observe failures immediately, while
1866
+ // preserving the rejected barrier for the next caller's safety check.
1867
+ cleanupPromise.catch(error => {
1868
+ Log.warn('Device cancellation cleanup failed', { errorCode: error?.errorCode });
1869
+ });
1778
1870
  },
1779
1871
  removePrePendingCallPromise: (connectId: string, promise: Promise<void>) => {
1780
1872
  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,
@@ -33,6 +33,8 @@ export default class TransportManager {
33
33
 
34
34
  static reactNativeInit = false;
35
35
 
36
+ static webUsbInit = false;
37
+
36
38
  static protocolV1MessageSchema: ProtocolV1MessageSchema = 'v1CurrentSchema';
37
39
 
38
40
  static plugin: LowlevelTransportSharedPlugin | null = null;
@@ -42,6 +44,17 @@ export default class TransportManager {
42
44
  this.defaultMessages = DataManager.getProtobufMessages();
43
45
  this.currentMessages = this.defaultMessages;
44
46
  this.protocolV1MessageSchema = 'v1CurrentSchema';
47
+ this.webUsbInit = false;
48
+ }
49
+
50
+ static async ensureInitialized() {
51
+ const env = DataManager.getSettings('env');
52
+ if (env !== 'webusb' && env !== 'desktop-webusb') return;
53
+ if (this.webUsbInit) return;
54
+ // The emitter registers USB disconnect events; this must happen even when
55
+ // schema configuration is intentionally deferred during discovery.
56
+ await this.transport.init(WebUsbLogger, DevicePool.emitter);
57
+ this.webUsbInit = true;
45
58
  }
46
59
 
47
60
  static async configure() {
@@ -69,9 +82,7 @@ export default class TransportManager {
69
82
  } else if (env === 'desktop-web-ble') {
70
83
  await this.transport.init(WebBleLogger, DevicePool.emitter);
71
84
  } else if (env === 'webusb' || env === 'desktop-webusb') {
72
- // The emitter is what turns a navigator.usb 'disconnect' into a
73
- // DEVICE.DISCONNECT; without it WebUSB never reports device removal.
74
- await this.transport.init(WebUsbLogger, DevicePool.emitter);
85
+ await this.ensureInitialized();
75
86
  } else {
76
87
  await this.transport.init(HttpLogger);
77
88
  }
@@ -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,70 @@ 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
+ // The connector drops the link silently, so without this line a
1628
+ // user-cancel teardown is indistinguishable in field logs from an
1629
+ // idle keep-alive release or a device that left on its own.
1630
+ Log.debug(
1631
+ 'interruptionFromUser: disconnecting device without acquire, mainId:',
1632
+ mainId
1633
+ );
1634
+ await deviceConnector.disconnect(mainId);
1635
+ }
1636
+ if (this.connectionAttempt === attempt) this.markTransportDisconnected();
1637
+ }
1638
+ await commands?.cancel();
1639
+ } catch (cleanupError) {
1640
+ Log.warn('User cancellation cleanup failed; disconnecting device', cleanupError);
1641
+ if (mainId && deviceConnector?.disconnect) {
1642
+ await deviceConnector.disconnect(mainId).catch(disconnectError => {
1643
+ Log.debug('User cancellation fallback disconnect failed', disconnectError);
1644
+ });
1645
+ }
1646
+ if (this.connectionAttempt === attempt) this.markTransportDisconnected();
1647
+ } finally {
1648
+ runPromise?.reject(error);
1649
+ if (this.runPromise === runPromise) this.runPromise = null;
1650
+ await cleanupPromise?.catch(() => undefined);
1617
1651
  }
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);
1652
+ })();
1653
+ // Keep the settled promise for this attempt so a duplicate close/finally
1654
+ // cannot send a second wire Cancel. A new attempt gets its own cleanup.
1655
+ this.userInterruption = { attempt, promise };
1656
+ return promise;
1627
1657
  }
1628
1658
 
1629
1659
  setCancelableAction(callback: (err?: Error) => Promise<unknown>) {
1630
- this.cancelableAction = (e?: Error) =>
1660
+ const action = (e?: Error) =>
1631
1661
  callback(e)
1632
1662
  .catch(e2 => {
1633
1663
  Log.debug('cancelableAction error', e2);
1634
1664
  })
1635
1665
  .finally(() => {
1636
- this.clearCancelableAction();
1666
+ if (this.cancelableAction === action) this.clearCancelableAction();
1637
1667
  });
1668
+ this.cancelableAction = action;
1638
1669
  }
1639
1670
 
1640
1671
  clearCancelableAction() {
@@ -268,6 +268,7 @@ export async function getProtocolV2WalletSession(
268
268
  ? device.getInternalState()
269
269
  : undefined;
270
270
  let response;
271
+ let walletSelectionRequested = false;
271
272
  let resumed = false;
272
273
  let walletStatusRefreshed = false;
273
274
  const markWalletStatusRefreshed = () => {
@@ -482,6 +483,7 @@ export async function getProtocolV2WalletSession(
482
483
  throw ERRORS.TypedError(HardwareErrorCode.WalletSessionInvalid);
483
484
  }
484
485
  await lockAttachPinBeforePassphraseSelection();
486
+ walletSelectionRequested = true;
485
487
  response = await selectDeviceSession(
486
488
  device,
487
489
  expectedPassphraseState,
@@ -508,6 +510,11 @@ export async function getProtocolV2WalletSession(
508
510
  device.clearInternalState();
509
511
  throw ERRORS.TypedError(HardwareErrorCode.WalletSessionInvalid);
510
512
  }
513
+ // Report a fresh selection mismatch before asking for input again.
514
+ if (walletSelectionRequested) {
515
+ clearCurrentWalletSession();
516
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckPassphraseStateError);
517
+ }
511
518
  if (options?.onlyMainPin) {
512
519
  device.clearStandardInternalState?.();
513
520
  await selectStandardWallet(true);
@@ -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