@onekeyfe/hd-core 1.2.2-alpha.120 → 1.2.2-alpha.122

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.
@@ -1,4 +1,4 @@
1
- import { canonicalizePro2BleAdvertisementName } from '@onekeyfe/hd-shared';
1
+ import { EDeviceType, canonicalizePro2BleAdvertisementName } from '@onekeyfe/hd-shared';
2
2
 
3
3
  import { BaseMethod } from './BaseMethod';
4
4
  import TransportManager from '../data-manager/TransportManager';
@@ -6,10 +6,80 @@ import { DataManager } from '../data-manager';
6
6
  import { LoggerNames, getDeviceTypeByBleName, getLogger } from '../utils';
7
7
  import { DevicePool } from '../device/DevicePool';
8
8
 
9
+ import type { SearchDevice } from '../types/device';
9
10
  import type DeviceConnector from '../device/DeviceConnector';
11
+ import type { OneKeyDeviceInfo as DeviceDescriptor } from '@onekeyfe/hd-transport';
12
+ import type { Device } from '../device/Device';
10
13
 
11
14
  const Log = getLogger(LoggerNames.DevicePool);
12
15
 
16
+ type RequestQueueLookup = {
17
+ getRequestTasksIdByConnectId: (connectId: string) => number[];
18
+ getTask: (requestId: number) => { method: { device?: Device } } | undefined;
19
+ };
20
+
21
+ const getDescriptorKeys = (descriptor: DeviceDescriptor) =>
22
+ [descriptor.path, descriptor.id].filter(
23
+ (key): key is string => typeof key === 'string' && key.length > 0
24
+ );
25
+
26
+ const isOwnedByActiveWebUsbRequest = (
27
+ descriptor: DeviceDescriptor,
28
+ requestQueue?: RequestQueueLookup
29
+ ) => {
30
+ if (!requestQueue) return false;
31
+ return getDescriptorKeys(descriptor).some(
32
+ key => requestQueue.getRequestTasksIdByConnectId(key).length > 0
33
+ );
34
+ };
35
+
36
+ const getUsbHandle = (descriptor?: DeviceDescriptor) =>
37
+ (descriptor as { device?: unknown } | undefined)?.device;
38
+
39
+ const getOwningRequestDevice = (
40
+ descriptor: DeviceDescriptor,
41
+ requestQueue?: RequestQueueLookup
42
+ ) => {
43
+ if (!requestQueue) return undefined;
44
+ const usbHandle = getUsbHandle(descriptor);
45
+ // A path alone can be shared (the all-zero bootloader path, a synthesized serial-less
46
+ // path), so the owner's identity is only reported for the USB handle it actually bound.
47
+ if (usbHandle === undefined) return undefined;
48
+ for (const key of getDescriptorKeys(descriptor)) {
49
+ for (const requestId of requestQueue.getRequestTasksIdByConnectId(key)) {
50
+ const device = requestQueue.getTask(requestId)?.method.device;
51
+ if (
52
+ device?.features &&
53
+ (device.mainId === key || device.originalDescriptor?.path === key) &&
54
+ getUsbHandle(device.originalDescriptor) === usbHandle
55
+ ) {
56
+ return device;
57
+ }
58
+ }
59
+ }
60
+ return undefined;
61
+ };
62
+
63
+ // A USB path is a routing key (the USB serial, a synthesized usb-vid-pid-name, or the
64
+ // bootloader placeholder), not a hardware identity, so it never fills uuid/serialNo.
65
+ const toSearchDeviceFromDescriptor = (descriptor: DeviceDescriptor): SearchDevice => {
66
+ const connectId = descriptor.path || descriptor.id || null;
67
+ return {
68
+ connectId,
69
+ uuid: '',
70
+ serialNo: null,
71
+ deviceId: null,
72
+ deviceType: EDeviceType.Unknown,
73
+ name: descriptor.name || connectId || '',
74
+ commType: descriptor.commType,
75
+ };
76
+ };
77
+
78
+ const toSearchDevice = (device: Device): SearchDevice | null => {
79
+ const message = device.toMessageObject();
80
+ return message ? (message as SearchDevice) : null;
81
+ };
82
+
13
83
  export default class SearchDevices extends BaseMethod {
14
84
  connector?: DeviceConnector;
15
85
 
@@ -19,19 +89,31 @@ export default class SearchDevices extends BaseMethod {
19
89
  this.skipForceUpdateCheck = true;
20
90
  }
21
91
 
22
- async run() {
23
- await TransportManager.configure();
92
+ async run(): Promise<SearchDevice[]> {
93
+ const env = DataManager.getSettings('env');
94
+ const isWebUsb = env === 'webusb' || env === 'desktop-webusb';
95
+ const requestQueue = this.context?.requestQueue;
96
+ const hasActiveWebUsbRequest = isWebUsb && (requestQueue?.getRequestTasksId().length ?? 0) > 0;
97
+ // Bring up WebUSB even when schema configuration is deferred while a
98
+ // business request owns the discovery lock. Failures here used to be
99
+ // swallowed by configure(); keep searchDevices resolving empty.
100
+ if (isWebUsb) {
101
+ try {
102
+ await TransportManager.ensureInitialized();
103
+ } catch (error) {
104
+ Log.debug('WebUSB bring-up unavailable', error);
105
+ }
106
+ }
107
+ if (!hasActiveWebUsbRequest) await TransportManager.configure();
24
108
  const deviceDiff = await this.connector?.enumerate();
25
109
  const devicesDescriptor = deviceDiff?.descriptors ?? [];
26
110
 
27
- const env = DataManager.getSettings('env');
28
-
29
111
  /**
30
112
  * No need to call features during Bluetooth scaning
31
113
  * to avoid device pairing
32
114
  */
33
115
  if (DataManager.isBleConnect(env)) {
34
- const devices = [];
116
+ const devices: SearchDevice[] = [];
35
117
  const seenIds = new Set<string>();
36
118
 
37
119
  for (const device of devicesDescriptor) {
@@ -56,30 +138,42 @@ export default class SearchDevices extends BaseMethod {
56
138
  return devices;
57
139
  }
58
140
 
59
- const deviceList = [];
141
+ const deviceList: SearchDevice[] = [];
60
142
  for (const descriptor of devicesDescriptor) {
61
- try {
62
- // Discovery is best effort. Browsers may retain WebUSB grants for devices that
63
- // are offline, busy, or not ready, so one descriptor must not abort the scan.
64
- const result = await DevicePool.getDevices([descriptor], descriptor.path, {
65
- // Discovery must actively identify the protocol instead of trusting a caller hint.
66
- connectProtocol: undefined,
67
- forceProtocolDetection: true,
68
- refreshRuntimeState: true,
69
- });
70
- deviceList.push(...result.deviceList);
71
- } catch (error) {
72
- const errorCode =
73
- error && typeof error === 'object' && 'errorCode' in error
74
- ? (error as { errorCode?: unknown }).errorCode
75
- : undefined;
76
- Log.debug('Skip unavailable device during search', {
77
- path: descriptor.path,
78
- ...(errorCode !== undefined ? { errorCode } : {}),
79
- });
143
+ if (hasActiveWebUsbRequest && isOwnedByActiveWebUsbRequest(descriptor, requestQueue)) {
144
+ const cached = DevicePool.getDeviceByPath(descriptor.path);
145
+ const known = cached?.features ? cached : getOwningRequestDevice(descriptor, requestQueue);
146
+ const message = known ? toSearchDevice(known) : null;
147
+ // Do not probe a path an in-flight request already owns. A cache miss is still a
148
+ // connected device: report the owning request's device, or the path with no identity.
149
+ deviceList.push(message ?? toSearchDeviceFromDescriptor(descriptor));
150
+ } else {
151
+ try {
152
+ // Discovery is best effort. Browsers may retain WebUSB grants for devices that
153
+ // are offline, busy, or not ready, so one descriptor must not abort the scan.
154
+ const result = await DevicePool.getDevices([descriptor], descriptor.path, {
155
+ // Discovery must actively identify the protocol instead of trusting a caller hint.
156
+ connectProtocol: undefined,
157
+ forceProtocolDetection: true,
158
+ refreshRuntimeState: true,
159
+ });
160
+ for (const device of result.deviceList) {
161
+ const message = toSearchDevice(device);
162
+ if (message) deviceList.push(message);
163
+ }
164
+ } catch (error) {
165
+ const errorCode =
166
+ error && typeof error === 'object' && 'errorCode' in error
167
+ ? (error as { errorCode?: unknown }).errorCode
168
+ : undefined;
169
+ Log.debug('Skip unavailable device during search', {
170
+ path: descriptor.path,
171
+ ...(errorCode !== undefined ? { errorCode } : {}),
172
+ });
173
+ }
80
174
  }
81
175
  }
82
176
 
83
- return deviceList.map(device => device.toMessageObject());
177
+ return deviceList;
84
178
  }
85
179
  }
@@ -7,21 +7,74 @@ import AllNetworkGetAddressBase from './AllNetworkGetAddressBase';
7
7
  import type { CoreApi } from '../../types';
8
8
  import type {
9
9
  AllNetworkAddress,
10
- AllNetworkAddressParams,
11
10
  AllNetworkGetAddressParams,
12
11
  } from '../../types/api/allNetworkGetAddress';
13
12
 
14
- type MethodParams = {
15
- methodName: keyof CoreApi;
16
- params: Parameters<CoreApi[keyof CoreApi]>[0];
17
- _originRequestParams: AllNetworkAddressParams;
18
- _originalIndex: number;
19
- };
13
+ type MethodParams = ReturnType<AllNetworkGetAddressBase['generateMethodName']>;
20
14
 
21
15
  export default class AllNetworkGetAddress extends AllNetworkGetAddressBase {
16
+ private checkAborted() {
17
+ if (this.abortController?.signal.aborted) {
18
+ throw new Error(HardwareErrorCodeMessage[HardwareErrorCode.RepeatUnlocking]);
19
+ }
20
+ }
21
+
22
+ private async callAddressGroup(
23
+ methodName: keyof CoreApi,
24
+ params: MethodParams[],
25
+ rootFingerprint: number
26
+ ): Promise<AllNetworkAddress[]> {
27
+ const methodCallParams = { bundle: params.map(param => ({ ...param.params })) };
28
+ if (!this.device.isProtocolV2() || params.length === 1) {
29
+ return this.callMethod(methodName, methodCallParams, rootFingerprint);
30
+ }
31
+
32
+ const postedAddressCounts = new Map<string, number>();
33
+ let runningIndividually = false;
34
+ const postMessage: typeof this.postMessage = message => {
35
+ if (message.type === UI_REQUEST.PREVIOUS_ADDRESS_RESULT) {
36
+ const { path, address } = message.payload.data;
37
+ const key = JSON.stringify([path, address]);
38
+ const count = postedAddressCounts.get(key) ?? 0;
39
+ if (runningIndividually && count > 0) {
40
+ postedAddressCounts.set(key, count - 1);
41
+ return;
42
+ }
43
+ if (!runningIndividually) postedAddressCounts.set(key, count + 1);
44
+ }
45
+ this.postMessage(message);
46
+ };
47
+
48
+ // Only silent reads may be replayed. Forward their notifications immediately,
49
+ // then suppress matching retry copies by count so repeated inputs still emit.
50
+ if (params.every(param => param._originRequestParams.showOnOneKey === false)) {
51
+ const response = await this.callMethod(
52
+ methodName,
53
+ methodCallParams,
54
+ rootFingerprint,
55
+ postMessage
56
+ );
57
+ // Skippable errors become failed items; link, cancellation and wallet errors throw.
58
+ if (response.some(item => item.success)) return response;
59
+ }
60
+
61
+ runningIndividually = true;
62
+ const responses: AllNetworkAddress[] = [];
63
+ for (const param of params) {
64
+ this.checkAborted();
65
+ const response = await this.callMethod(
66
+ methodName,
67
+ { bundle: [{ ...param.params }] },
68
+ rootFingerprint,
69
+ postMessage
70
+ );
71
+ responses.push(...response);
72
+ }
73
+ return responses;
74
+ }
75
+
22
76
  async getAllNetworkAddress(rootFingerprint: number) {
23
77
  const responses: AllNetworkAddress[] = [];
24
- const resultMap: Record<string, AllNetworkAddress> = {};
25
78
  const { bundle } = this.payload as AllNetworkGetAddressParams;
26
79
 
27
80
  const methodParams = bundle.map((param, index) =>
@@ -44,55 +97,13 @@ export default class AllNetworkGetAddress extends AllNetworkGetAddressBase {
44
97
 
45
98
  let processed = 0;
46
99
  for (const [methodName, params] of methodGroups.entries()) {
47
- const methodCallParams = {
48
- bundle: params.map(param => ({
49
- ...param.params,
50
- })),
51
- };
52
-
53
- if (this.abortController?.signal.aborted) {
54
- throw new Error(HardwareErrorCodeMessage[HardwareErrorCode.RepeatUnlocking]);
55
- }
56
- const isProtocolV2 = this.device.isProtocolV2();
57
- // Displayed addresses must not be replayed if a later item fails.
58
- const runIndividually =
59
- isProtocolV2 &&
60
- params.length > 1 &&
61
- params.some(param => param._originRequestParams.showOnOneKey !== false);
62
- let response: AllNetworkAddress[] = [];
63
- if (!runIndividually) {
64
- response = await this.callMethod(methodName, methodCallParams, rootFingerprint);
65
- }
66
-
67
- // callMethod returns failures only for skippable errors; link, cancellation,
68
- // and wallet errors throw. Retry silent reads separately to isolate a bad
69
- // path or unsupported coin while reusing the already selected wallet.
70
- if (
71
- isProtocolV2 &&
72
- params.length > 1 &&
73
- (runIndividually || response.every(item => !item.success))
74
- ) {
75
- response = [];
76
- for (const param of params) {
77
- if (this.abortController?.signal.aborted) {
78
- throw new Error(HardwareErrorCodeMessage[HardwareErrorCode.RepeatUnlocking]);
79
- }
80
- const itemResponse = await this.callMethod(
81
- methodName,
82
- { bundle: [{ ...param.params }] },
83
- rootFingerprint
84
- );
85
- response.push(...itemResponse);
86
- }
87
- }
88
-
89
- if (this.abortController?.signal.aborted) {
90
- throw new Error(HardwareErrorCodeMessage[HardwareErrorCode.RepeatUnlocking]);
91
- }
100
+ this.checkAborted();
101
+ const response = await this.callAddressGroup(methodName, params, rootFingerprint);
102
+ this.checkAborted();
92
103
 
93
104
  for (let index = 0; index < params.length; index++) {
94
105
  const { _originRequestParams, _originalIndex } = params[index];
95
- resultMap[`${_originalIndex}`] = {
106
+ responses[_originalIndex] = {
96
107
  ..._originRequestParams,
97
108
  ...response[index],
98
109
  };
@@ -105,11 +116,7 @@ export default class AllNetworkGetAddress extends AllNetworkGetAddressBase {
105
116
  }
106
117
  }
107
118
 
108
- for (let i = 0; i < bundle.length; i++) {
109
- responses.push(resultMap[i]);
110
- }
111
-
112
119
  this.abortController = null;
113
- return Promise.resolve(responses);
120
+ return responses;
114
121
  }
115
122
  }
@@ -348,7 +348,8 @@ export default abstract class AllNetworkGetAddressBase extends BaseMethod<
348
348
  params: any & {
349
349
  bundle: (any & { _originRequestParams: CommonResponseParams })[];
350
350
  },
351
- rootFingerprint: number
351
+ rootFingerprint: number,
352
+ postMessage = this.postMessage
352
353
  ) {
353
354
  const method: BaseMethod = findMethod({
354
355
  event: IFRAME.CALL,
@@ -362,7 +363,7 @@ export default abstract class AllNetworkGetAddressBase extends BaseMethod<
362
363
  });
363
364
 
364
365
  method.connector = this.connector;
365
- method.postMessage = this.postMessage;
366
+ method.postMessage = postMessage;
366
367
  if (this.context) {
367
368
  method.setContext?.(this.context);
368
369
  }
@@ -506,7 +507,11 @@ export default abstract class AllNetworkGetAddressBase extends BaseMethod<
506
507
  async run() {
507
508
  this.loadingCleanupInBackground = false;
508
509
  try {
509
- if (this.device.isProtocolV2() && this.device.getCurrentDeviceType() === EDeviceType.Pro2) {
510
+ if (
511
+ this.device.isProtocolV2() &&
512
+ (this.device.getCurrentDeviceType() === EDeviceType.Pro2 ||
513
+ this.device.getCurrentDeviceType() === EDeviceType.Neo)
514
+ ) {
510
515
  const protocolInfo = await this.device.ensureProtocolV2RuntimeContext();
511
516
  if (supportsProtocolV2Message(protocolInfo, 60461)) {
512
517
  const { commands } = this.device;
package/src/core/index.ts CHANGED
@@ -84,7 +84,7 @@ import type { BaseMethod } from '../api/BaseMethod';
84
84
 
85
85
  const Log = getLogger(LoggerNames.Core);
86
86
  const PRE_INITIALIZE_TTL_MS = 60 * 1000;
87
- const PRE_PENDING_CALL_TIMEOUT_MS = 15 * 1000;
87
+ const PRE_PENDING_CALL_TIMEOUT_MS = 5 * 1000;
88
88
  const PRO2_USB_SIGNING_COOLDOWN_MS = 1000;
89
89
 
90
90
  // Dedup/coalesce state for "pre-warm signal" methods (isPreWarmSignal),
@@ -183,6 +183,7 @@ export const callAPI = async (context: CoreContext, message: CoreMessage) => {
183
183
  }
184
184
  };
185
185
  method.setContext?.(context);
186
+ method.context = context;
186
187
 
187
188
  method.requestContext = createRequestContext(method.responseID, method.name, {
188
189
  sdkInstanceId: context.sdkInstanceId,
@@ -203,7 +204,12 @@ export const callAPI = async (context: CoreContext, message: CoreMessage) => {
203
204
  if (!method.useDevice) {
204
205
  updateMethodRequestContext(method, { status: 'running' });
205
206
  try {
206
- 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();
207
213
  completeMethodRequestContext(method);
208
214
  return createResponseMessage(method.responseID, true, response);
209
215
  } catch (error) {
@@ -315,25 +321,27 @@ const waitForPendingPromise = async (
315
321
  if (pendingPromise) {
316
322
  Log.debug('pre pending call promise before call method, wait for it');
317
323
  let timer: ReturnType<typeof setTimeout> | undefined;
324
+ let timedOut = false;
325
+ let completed = false;
318
326
  try {
319
327
  await Promise.race([
320
328
  pendingPromise,
321
- new Promise<void>((_, reject) => {
329
+ new Promise<void>(resolve => {
322
330
  timer = setTimeout(() => {
323
- reject(
324
- ERRORS.TypedError(
325
- HardwareErrorCode.DeviceBusy,
326
- 'Previous device cancellation is still draining'
327
- )
328
- );
331
+ timedOut = true;
332
+ resolve();
329
333
  }, PRE_PENDING_CALL_TIMEOUT_MS);
330
334
  }),
331
335
  ]);
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);
336
+ completed = !timedOut;
335
337
  } finally {
336
338
  if (timer) clearTimeout(timer);
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);
342
+ }
343
+ if (timedOut) {
344
+ Log.warn('pre pending call promise timed out before call method', { connectId });
337
345
  }
338
346
  Log.debug('pre pending call promise before call method done');
339
347
  }
@@ -411,7 +419,26 @@ const onCallDevice = async (
411
419
  * Polling to ensure successful connection
412
420
  */
413
421
  const pollingId = pollingManager.start(connectId);
414
- device = await ensureConnected(context, method, connectId, pollingId, method.abortSignal);
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
+ }
415
442
  if (method.abortSignal?.aborted) {
416
443
  throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
417
444
  }
@@ -1200,6 +1227,7 @@ const ensureConnected = async (
1200
1227
  const POLL_INTERVAL_TIME = (method.payload && method.payload.pollIntervalTime) || 1000;
1201
1228
  const TIME_OUT = (method.payload && method.payload.timeout) || 10000;
1202
1229
  let timer: ReturnType<typeof setTimeout> | null = null;
1230
+ let lastInitializeError: any;
1203
1231
  Log.debug(
1204
1232
  `EnsureConnected function start, MAX_RETRY_COUNT=${MAX_RETRY_COUNT}, POLL_INTERVAL_TIME=${POLL_INTERVAL_TIME} `
1205
1233
  );
@@ -1240,7 +1268,9 @@ const ensureConnected = async (
1240
1268
  Log.debug('EnsureConnected function try count: ', tryCount, ' poll interval time: ', time);
1241
1269
  try {
1242
1270
  await initDeviceList(method);
1271
+ lastInitializeError = undefined;
1243
1272
  } catch (error) {
1273
+ lastInitializeError = error;
1244
1274
  Log.debug('device list error: ', error);
1245
1275
  if (
1246
1276
  [
@@ -1255,6 +1285,7 @@ const ensureConnected = async (
1255
1285
  }
1256
1286
  if (error.errorCode === HardwareErrorCode.TransportNotConfigured) {
1257
1287
  await TransportManager.configure();
1288
+ lastInitializeError = undefined;
1258
1289
  }
1259
1290
  }
1260
1291
 
@@ -1352,14 +1383,22 @@ const ensureConnected = async (
1352
1383
  clearTimeout(timer);
1353
1384
  }
1354
1385
  Log.debug('EnsureConnected get to max try count, will return: ', tryCount);
1355
- // Browser WebUSB needs permission prompt, desktop WebUSB doesn't
1356
- // skipWebDevicePrompt can override this behavior for special cases
1357
- 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) {
1358
1399
  postMessage(createUiMessage(UI_REQUEST.WEB_DEVICE_PROMPT_ACCESS_PERMISSION));
1359
- reject(ERRORS.TypedError(HardwareErrorCode.WebDeviceNotFoundOrNeedsPermission));
1360
- } else {
1361
- reject(ERRORS.TypedError(HardwareErrorCode.DeviceNotFound));
1362
1400
  }
1401
+ reject(errorToReject);
1363
1402
  return;
1364
1403
  }
1365
1404
 
@@ -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
  }
@@ -1624,6 +1624,13 @@ export class Device extends EventEmitter {
1624
1624
  } else if (!acquired) {
1625
1625
  // Abort setup without acquiring a session just to send Cancel.
1626
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
+ );
1627
1634
  await deviceConnector.disconnect(mainId);
1628
1635
  }
1629
1636
  if (this.connectionAttempt === attempt) this.markTransportDisconnected();