@onekeyfe/hd-transport-web-device 1.2.0-alpha.99 → 1.2.0

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.
package/src/webusb.ts CHANGED
@@ -8,19 +8,21 @@ import transport, {
8
8
  PROTOCOL_V2_FRAME_MAX_BYTES,
9
9
  ProtocolV2LinkError,
10
10
  ProtocolV2UsbTransportBase,
11
+ TRANSPORT_EVENT,
11
12
  probeProtocolV2 as probeProtocolV2Helper,
12
13
  } from '@onekeyfe/hd-transport';
13
14
  import {
14
15
  ERRORS,
15
16
  HardwareErrorCode,
16
17
  ONEKEY_WEBUSB_FILTER,
18
+ inferProtocolHintFromUsbId,
17
19
  isKnownTrezorWebUsbDevice,
20
+ resolveOneKeyUsbDevicePath,
18
21
  wait,
19
22
  } from '@onekeyfe/hd-shared';
20
23
  import ByteBuffer from 'bytebuffer';
21
24
 
22
- import { createTransportCallLog, shouldSuppressHighVolumeCallLog } from './transportLog';
23
-
25
+ import type EventEmitter from 'events';
24
26
  import type {
25
27
  AcquireInput,
26
28
  OneKeyDeviceInfoBase,
@@ -47,9 +49,6 @@ const PACKET_IO_RETRY_DELAY = 300;
47
49
  const PROTOCOL_V1_PROBE_TIMEOUT = 5000;
48
50
  const PROTOCOL_V2_PROBE_TIMEOUT = 1000;
49
51
  const EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS = 2;
50
- function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
51
- return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
52
- }
53
52
 
54
53
  /**
55
54
  * Device information with path and WebUSB device instance
@@ -71,6 +70,32 @@ interface TransferCancelToken {
71
70
  cancelled: boolean;
72
71
  }
73
72
 
73
+ /**
74
+ * The navigator.usb disconnect listener is module-scoped and attached at most
75
+ * once: listeners on navigator.usb are global and never garbage collected, so a
76
+ * per-instance listener would retain every transport instance created across
77
+ * SDK re-initializations. Events are routed to the most recently initialized
78
+ * transport instance instead.
79
+ */
80
+ let activeWebUsbTransport: WebUsbTransport | undefined;
81
+ let usbDisconnectListenerAttached = false;
82
+
83
+ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
84
+ activeWebUsbTransport = instance;
85
+ if (usbDisconnectListenerAttached) return;
86
+ usbDisconnectListenerAttached = true;
87
+ usb.addEventListener('disconnect', event => {
88
+ if (!event.device) return;
89
+ const path = resolveOneKeyUsbDevicePath(event.device);
90
+ if (!path) return;
91
+ activeWebUsbTransport?.markProtocolStale(path);
92
+ // WebUSB has no device-list poller behind it, so this event is the only
93
+ // signal that the device is gone. Without it consumers never see a
94
+ // DEVICE.DISCONNECT for USB (OK-60486).
95
+ activeWebUsbTransport?.emitDeviceDisconnect(path, event.device);
96
+ });
97
+ }
98
+
74
99
  export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
75
100
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
76
101
 
@@ -82,8 +107,29 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
82
107
  /** Per-path protocol type detected by active wire-level probe. */
83
108
  private deviceProtocol: Map<string, ProtocolType> = new Map();
84
109
 
110
+ /** Protocols previously confirmed by an active response for this transport instance. */
111
+ private confirmedDeviceProtocols: Map<string, ProtocolType> = new Map();
112
+
85
113
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
86
114
 
115
+ /**
116
+ * Paths whose cached protocol must be re-probed on the next acquire (a USB
117
+ * disconnect was seen, or a transfer-level reconnect happened mid-call).
118
+ */
119
+ private staleProtocolPaths: Set<string> = new Set();
120
+
121
+ /** Paths currently acquired (between a successful acquire() and its release()). */
122
+ private acquiredPaths: Set<string> = new Set();
123
+
124
+ /**
125
+ * The exact USBDevice object each cached protocol was probed against. The
126
+ * browser returns the same object identity for a device as long as it stays
127
+ * connected, and a replug/reboot always yields a new object — so an identity
128
+ * mismatch proves the device was re-enumerated since the probe, even when the
129
+ * disconnect event itself was delayed or missed.
130
+ */
131
+ private probedDeviceObjects: Map<string, USBDevice> = new Map();
132
+
87
133
  /** Per-path USB endpoint / interface numbers (discovered from USB descriptors) */
88
134
  private deviceEndpoints: Map<string, DeviceEndpoints> = new Map();
89
135
 
@@ -97,6 +143,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
97
143
 
98
144
  usb?: USB;
99
145
 
146
+ emitter?: EventEmitter;
147
+
100
148
  /**
101
149
  * Cached list of connected devices
102
150
  * This is essential for maintaining device references between operations
@@ -120,8 +168,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
120
168
  /**
121
169
  * Initialize WebUSB transport
122
170
  */
123
- init(logger: any) {
171
+ init(logger: any, emitter?: EventEmitter) {
124
172
  this.Log = logger;
173
+ this.emitter = emitter;
125
174
 
126
175
  const { usb } = navigator;
127
176
  if (!usb) {
@@ -131,6 +180,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
131
180
  );
132
181
  }
133
182
  this.usb = usb;
183
+ registerActiveWebUsbTransport(this, usb);
184
+ }
185
+
186
+ /**
187
+ * Announce that a USB device left. Called from the module-scoped disconnect
188
+ * listener, which is why it is public rather than inlined.
189
+ */
190
+ emitDeviceDisconnect(path: string, device?: USBDevice) {
191
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
192
+ name: device?.productName ?? '',
193
+ id: path,
194
+ connectId: path,
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Protocol type is a property of the physical device keyed by USB serial number.
200
+ * It can only change across a device reboot (e.g. normal ↔ bootloader mode), and
201
+ * a reboot always surfaces as a USB disconnect. Disconnects (and transfer-level
202
+ * reconnects, which cover a missed disconnect event) only MARK the cached probe
203
+ * result stale instead of deleting it: an in-flight session keeps using the old
204
+ * value so the transfer-level reconnect retries can absorb a transient
205
+ * re-enumeration exactly as they did before the cache existed, while the next
206
+ * acquire re-probes from scratch.
207
+ */
208
+ markProtocolStale(path: string) {
209
+ this.staleProtocolPaths.add(path);
210
+ this.probedDeviceObjects.delete(path);
134
211
  }
135
212
 
136
213
  /**
@@ -203,22 +280,24 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
203
280
  (desc: { vendorId: number; productId: number }) =>
204
281
  dev.vendorId === desc.vendorId && dev.productId === desc.productId
205
282
  );
206
- const hasSerialNumber = typeof dev.serialNumber === 'string' && dev.serialNumber.length > 0;
207
- return isOneKey && hasSerialNumber && !isKnownTrezorWebUsbDevice(dev);
283
+ return isOneKey && !isKnownTrezorWebUsbDevice(dev);
208
284
  });
209
285
 
210
- this.deviceList = onekeyDevices.map(device => {
211
- const path = device.serialNumber as string;
212
- const protocolHint = inferProtocolHintFromDeviceName(device.productName);
286
+ this.deviceList = onekeyDevices.flatMap(device => {
287
+ const path = resolveOneKeyUsbDevicePath(device);
288
+ if (!path) return [];
289
+ const protocolHint = inferProtocolHintFromUsbId(device.vendorId, device.productId);
213
290
  if (protocolHint) {
214
291
  this.deviceProtocolHints.set(path, protocolHint);
215
292
  }
216
293
 
217
- return {
218
- path,
219
- device,
220
- commType: 'webusb',
221
- };
294
+ return [
295
+ {
296
+ path,
297
+ device,
298
+ commType: 'webusb',
299
+ },
300
+ ];
222
301
  });
223
302
 
224
303
  return this.deviceList;
@@ -233,20 +312,85 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
233
312
  await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
234
313
  await this.closeOpenDevice(input.path);
235
314
  await this.connect(input.path ?? '', true);
236
- const deviceName = this.deviceList.find(device => device.path === input.path)?.device
237
- .productName;
238
- const protocolHint = input.expectedProtocol
239
- ? undefined
240
- : input.protocolHint ??
241
- this.deviceProtocolHints.get(input.path) ??
242
- inferProtocolHintFromDeviceName(deviceName);
243
- if (protocolHint) {
244
- this.deviceProtocolHints.set(input.path, protocolHint);
315
+ if (input.skipProtocolProbe) {
316
+ if (!input.expectedProtocol) {
317
+ throw ERRORS.TypedError(
318
+ HardwareErrorCode.RuntimeError,
319
+ 'skipProtocolProbe requires an expected protocol'
320
+ );
321
+ }
322
+ if (this.confirmedDeviceProtocols.get(input.path) !== input.expectedProtocol) {
323
+ throw ERRORS.TypedError(
324
+ HardwareErrorCode.RuntimeError,
325
+ 'skipProtocolProbe requires a previously confirmed protocol for this WebUSB endpoint'
326
+ );
327
+ }
328
+ this.staleProtocolPaths.delete(input.path);
329
+ this.deviceProtocol.set(input.path, input.expectedProtocol);
330
+ const currentDevice = this.deviceList.find(d => d.path === input.path)?.device;
331
+ if (currentDevice) {
332
+ this.probedDeviceObjects.set(input.path, currentDevice);
333
+ }
334
+ } else if (input.forceProtocolDetection) {
335
+ // Explicit recovery/discovery (e.g. detectDeviceConnectProtocol) must
336
+ // probe on the wire regardless of any cached result — the cached
337
+ // binding may be exactly what the caller is trying to recover from.
338
+ this.staleProtocolPaths.delete(input.path);
339
+ this.deviceProtocol.delete(input.path);
340
+ this.deviceProtocolHints.delete(input.path);
341
+ }
342
+ if (!input.skipProtocolProbe && this.staleProtocolPaths.has(input.path)) {
343
+ // The device disconnected (possibly rebooting into another mode) since
344
+ // the protocol was probed — drop the cache so it is re-probed below.
345
+ this.staleProtocolPaths.delete(input.path);
346
+ this.deviceProtocol.delete(input.path);
347
+ }
348
+ if (!input.skipProtocolProbe && this.deviceProtocol.has(input.path)) {
349
+ const currentDevice = this.deviceList.find(d => d.path === input.path)?.device;
350
+ if (!currentDevice || currentDevice !== this.probedDeviceObjects.get(input.path)) {
351
+ // The OS re-enumerated the device since the probe (a replug/reboot we
352
+ // may not have seen a disconnect event for) — the cached protocol can
353
+ // no longer be trusted; re-probe on the wire below.
354
+ this.deviceProtocol.delete(input.path);
355
+ }
356
+ }
357
+ const cachedProtocol = this.deviceProtocol.get(input.path);
358
+ if (
359
+ !input.skipProtocolProbe &&
360
+ cachedProtocol &&
361
+ input.expectedProtocol &&
362
+ cachedProtocol !== input.expectedProtocol
363
+ ) {
364
+ // The caller expects a different protocol than the cached probe result;
365
+ // the cache is stale — drop it and re-probe on the wire below.
366
+ this.deviceProtocol.delete(input.path);
367
+ }
368
+ if (!this.deviceProtocol.has(input.path)) {
369
+ const usbDevice = this.deviceList.find(device => device.path === input.path)?.device;
370
+ const protocolHint = input.expectedProtocol
371
+ ? undefined
372
+ : input.protocolHint ??
373
+ this.deviceProtocolHints.get(input.path) ??
374
+ inferProtocolHintFromUsbId(usbDevice?.vendorId, usbDevice?.productId);
375
+ if (protocolHint) {
376
+ this.deviceProtocolHints.set(input.path, protocolHint);
377
+ }
378
+ const detectedProtocol = await this.detectProtocol(
379
+ input.path,
380
+ input.expectedProtocol,
381
+ protocolHint
382
+ );
383
+ this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
384
+ const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
385
+ if (probedDevice) {
386
+ this.probedDeviceObjects.set(input.path, probedDevice);
387
+ }
245
388
  }
246
- await this.detectProtocol(input.path, input.expectedProtocol, protocolHint);
389
+ this.acquiredPaths.add(input.path);
247
390
  return await Promise.resolve(input.path);
248
391
  } catch (e) {
249
392
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
393
+ this.acquiredPaths.delete(input.path);
250
394
  await this.closeOpenDevice(input.path);
251
395
  throw e;
252
396
  }
@@ -266,7 +410,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
266
410
 
267
411
  private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
268
412
  return ERRORS.TypedError(
269
- HardwareErrorCode.RuntimeError,
413
+ HardwareErrorCode.DeviceInitializeFailed,
270
414
  `Protocol ${expected} probe timeout after ${attempts} attempts`
271
415
  );
272
416
  }
@@ -482,7 +626,25 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
482
626
  }
483
627
  }
484
628
 
629
+ /**
630
+ * With the protocol cache surviving release(), deviceProtocol presence no
631
+ * longer implies an active session. Guard call()/post() explicitly so a
632
+ * post-release straggler fails fast instead of silently reopening the device
633
+ * and driving it outside any session (pre-cache behavior: the deleted
634
+ * protocol entry produced the same fail-fast).
635
+ */
636
+ private assertAcquired(path: string) {
637
+ if (!this.acquiredPaths.has(path)) {
638
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
639
+ }
640
+ }
641
+
485
642
  async post(session: string, name: string, data: Record<string, unknown>) {
643
+ this.assertAcquired(session);
644
+ if (this.deviceProtocol.get(session) === 'V2') {
645
+ await this.sendProtocolV2UsbFlowControl(session, name, data);
646
+ return;
647
+ }
486
648
  await this.call(session, name, data);
487
649
  }
488
650
 
@@ -520,6 +682,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
520
682
  error
521
683
  )}`
522
684
  );
685
+ // The device dropped off the bus mid-call and may have rebooted into a
686
+ // different mode. The in-flight retry keeps the known protocol, but the
687
+ // next acquire must re-probe — this also self-heals a stale cache when the
688
+ // USB disconnect event itself was missed.
689
+ this.staleProtocolPaths.add(path);
523
690
  await wait(attempt * PACKET_IO_RETRY_DELAY);
524
691
 
525
692
  try {
@@ -753,6 +920,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
753
920
  if (this.messages == null) {
754
921
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
755
922
  }
923
+ this.assertAcquired(path);
756
924
 
757
925
  const device = await this.findDevice(path);
758
926
  if (!device) {
@@ -767,10 +935,6 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
767
935
  );
768
936
  }
769
937
 
770
- if (!shouldSuppressHighVolumeCallLog(name)) {
771
- this.Log.debug('transport call', createTransportCallLog(name, protocol, data));
772
- }
773
-
774
938
  if (protocol === 'V2') {
775
939
  return this.callProtocolV2(path, name, data, options);
776
940
  }
@@ -873,10 +1037,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
873
1037
  * Release device
874
1038
  */
875
1039
  async release(path: string) {
1040
+ this.acquiredPaths.delete(path);
876
1041
  await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
877
1042
  await this.closeOpenDevice(path);
878
- this.deviceProtocol.delete(path);
879
- this.deviceProtocolHints.delete(path);
1043
+ // Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
1044
+ // physical-device property, so the next acquire can skip the wire-level probe.
1045
+ // V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link
1046
+ // invalidation above, and any entry is re-probed after a USB disconnect or a
1047
+ // transfer-level reconnect (see markProtocolStale).
880
1048
  this.deviceEndpoints.delete(path);
881
1049
  }
882
1050