@onekeyfe/hd-transport-web-device 1.2.8-alpha.5 → 1.2.8-alpha.51

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.
@@ -0,0 +1,268 @@
1
+ import WebUsbTransport from '../src/webusb';
2
+ import { probeUsbDeviceIdentities } from '@onekeyfe/hd-common-connect-sdk/src';
3
+
4
+ import type { MessageFromOneKey, TransportCallOptions } from '@onekeyfe/hd-transport';
5
+
6
+ jest.mock('@onekeyfe/hd-core', () => jest.requireActual('../../core/src'));
7
+ jest.mock('../../core/src/data/config', () => ({
8
+ getSDKVersion: () => '1.0.0-test',
9
+ DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0-test/',
10
+ }));
11
+ jest.mock('@onekeyfe/hd-transport-web-device', () => ({
12
+ WebUsbTransport: jest.requireActual('../src/webusb').default,
13
+ }));
14
+ jest.mock('@onekeyfe/hd-transport-http', () => ({ __esModule: true, default: jest.fn() }));
15
+ jest.mock('@onekeyfe/hd-transport-lowlevel', () => ({}));
16
+ jest.mock('@onekeyfe/hd-transport-emulator', () => ({}));
17
+
18
+ describe('WebUSB identity probe ownership', () => {
19
+ let originalNavigator: PropertyDescriptor | undefined;
20
+ const device = {
21
+ vendorId: 0x1209,
22
+ productId: 0x53c1,
23
+ serialNumber: 'USB_PATH',
24
+ opened: false,
25
+ close: jest.fn(),
26
+ };
27
+ let disconnectListener: (event: { device: typeof device }) => void;
28
+ const usb = {
29
+ getDevices: jest.fn(),
30
+ addEventListener: jest.fn((_type, callback) => {
31
+ disconnectListener = callback;
32
+ }),
33
+ };
34
+ beforeEach(() => {
35
+ originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
36
+ device.opened = false;
37
+ device.close.mockReset().mockImplementation(() => {
38
+ device.opened = false;
39
+ return Promise.resolve();
40
+ });
41
+ usb.getDevices.mockResolvedValue([device]);
42
+ usb.addEventListener.mockClear();
43
+ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { usb } });
44
+ });
45
+ afterEach(() => {
46
+ if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
47
+ else delete (globalThis as { navigator?: Navigator }).navigator;
48
+ });
49
+
50
+ test('does not close a handle already opened by another operation', async () => {
51
+ const probe = new WebUsbTransport(true);
52
+ probe.init({ debug: jest.fn() });
53
+ device.opened = true;
54
+ await expect(probe.acquire({ path: 'USB_PATH' })).rejects.toThrow(
55
+ 'USB device is already in use'
56
+ );
57
+ expect(device.close).not.toHaveBeenCalled();
58
+ expect(usb.addEventListener).not.toHaveBeenCalled();
59
+ });
60
+
61
+ test('does not borrow a handle while a normal native open is pending', async () => {
62
+ const active = new WebUsbTransport();
63
+ const probe = new WebUsbTransport(true);
64
+ active.init({ debug: jest.fn() });
65
+ probe.init({ debug: jest.fn() });
66
+ let finishOpen!: () => void;
67
+ let started!: () => void;
68
+ const opening = new Promise<void>(resolve => {
69
+ finishOpen = resolve;
70
+ });
71
+ const entered = new Promise<void>(resolve => {
72
+ started = resolve;
73
+ });
74
+ const native = Object.assign(device, {
75
+ open: jest.fn(async () => {
76
+ started();
77
+ await opening;
78
+ device.opened = true;
79
+ }),
80
+ reset: jest.fn(),
81
+ configurations: [],
82
+ configuration: { configurationValue: 1 },
83
+ selectConfiguration: jest.fn(),
84
+ claimInterface: jest.fn(),
85
+ clearHalt: jest.fn(),
86
+ });
87
+ jest.spyOn(active, 'findDevice').mockResolvedValue(native as unknown as USBDevice);
88
+ const connecting = active.connectToDevice('USB_PATH', true);
89
+ await entered;
90
+ await expect(probe.acquire({ path: 'USB_PATH' })).rejects.toThrow(
91
+ 'USB device is already in use'
92
+ );
93
+ expect(device.close).not.toHaveBeenCalled();
94
+ finishOpen();
95
+ await connecting;
96
+ });
97
+
98
+ test('a normal connection cannot close an isolated probe handle and release closes the captured handle after re-enumeration', async () => {
99
+ const probe = new WebUsbTransport(true);
100
+ probe.init({ debug: jest.fn() });
101
+ const internals = probe as unknown as {
102
+ rotateProtocolV2UsbGeneration: () => Promise<void>;
103
+ detectProtocol: () => Promise<'V1'>;
104
+ };
105
+ jest.spyOn(internals, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
106
+ jest.spyOn(internals, 'detectProtocol').mockResolvedValue('V1');
107
+ jest.spyOn(probe, 'connect').mockImplementation(() => {
108
+ device.opened = true;
109
+ return Promise.resolve();
110
+ });
111
+ await probe.acquire({ path: 'USB_PATH' });
112
+ const active = new WebUsbTransport();
113
+ active.init({ debug: jest.fn() });
114
+ await expect(active.connectToDevice('USB_PATH', true)).rejects.toThrow(
115
+ 'USB device is already in use'
116
+ );
117
+ expect(device.close).not.toHaveBeenCalled();
118
+ const replacement = { ...device, close: jest.fn() };
119
+ usb.getDevices.mockResolvedValue([replacement]);
120
+ await probe.getConnectedDevices();
121
+ device.close.mockRejectedValueOnce(new Error('native close failed'));
122
+ await expect(probe.release('USB_PATH')).rejects.toThrow('native close failed');
123
+ await probe.enumerate();
124
+ expect(device.close).toHaveBeenCalledTimes(2);
125
+ expect(replacement.close).not.toHaveBeenCalled();
126
+ });
127
+
128
+ test('does not replace the active SDK disconnect observer', () => {
129
+ const active = new WebUsbTransport();
130
+ active.init({ debug: jest.fn() });
131
+ const probe = new WebUsbTransport(true);
132
+ probe.init({ debug: jest.fn() });
133
+ const activeDisconnect = jest.spyOn(active, 'markProtocolStale');
134
+ const probeDisconnect = jest.spyOn(probe, 'markProtocolStale');
135
+ disconnectListener({ device });
136
+ expect(activeDisconnect).toHaveBeenCalledWith('USB_PATH');
137
+ expect(probeDisconnect).not.toHaveBeenCalled();
138
+ });
139
+
140
+ test.each(['release', 'acquire failure'])(
141
+ 'keeps a probe handle protected after close fails during %s and recovers after cleanup',
142
+ async phase => {
143
+ Object.assign(device, {
144
+ open: jest.fn(() => {
145
+ device.opened = true;
146
+ return Promise.resolve();
147
+ }),
148
+ releaseInterface: jest.fn().mockResolvedValue(undefined),
149
+ reset: jest.fn(),
150
+ configurations: [],
151
+ configuration: { configurationValue: 1 },
152
+ selectConfiguration: jest.fn(),
153
+ claimInterface: jest.fn(),
154
+ clearHalt: jest.fn(),
155
+ });
156
+ const probe = new WebUsbTransport(true);
157
+ probe.init({ debug: jest.fn() });
158
+ const internals = probe as unknown as {
159
+ rotateProtocolV2UsbGeneration: () => Promise<void>;
160
+ detectProtocol: () => Promise<'V1'>;
161
+ };
162
+ jest.spyOn(internals, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
163
+ jest.spyOn(internals, 'detectProtocol').mockResolvedValue('V1');
164
+ jest.spyOn(probe, 'connect').mockImplementation(() => {
165
+ device.opened = true;
166
+ if (phase === 'acquire failure') return Promise.reject(new Error('probe failed'));
167
+ return Promise.resolve();
168
+ });
169
+ const closeFailure = new Error('native close failed');
170
+ device.close.mockRejectedValueOnce(closeFailure);
171
+ if (phase === 'release') {
172
+ await probe.acquire({ path: 'USB_PATH' });
173
+ await expect(probe.release('USB_PATH')).rejects.toBe(closeFailure);
174
+ } else {
175
+ await expect(probe.acquire({ path: 'USB_PATH' })).rejects.toBe(closeFailure);
176
+ }
177
+ expect(device.opened).toBe(true);
178
+ const active = new WebUsbTransport();
179
+ active.init({ debug: jest.fn() });
180
+ await expect(active.connectToDevice('USB_PATH', true)).rejects.toThrow(
181
+ 'USB device is already in use'
182
+ );
183
+ expect(device.close).toHaveBeenCalledTimes(1);
184
+
185
+ await probe.release('USB_PATH');
186
+ expect(device.opened).toBe(false);
187
+ await expect(active.connectToDevice('USB_PATH', true)).resolves.toBeUndefined();
188
+ expect(device.opened).toBe(true);
189
+ await active.release('USB_PATH');
190
+ }
191
+ );
192
+
193
+ test.each(['release', 'acquire failure'])(
194
+ 'the public probe retries failed %s cleanup on its next call',
195
+ async phase => {
196
+ const internals = WebUsbTransport.prototype as unknown as {
197
+ rotateProtocolV2UsbGeneration: () => Promise<void>;
198
+ detectProtocol: () => Promise<'V1'>;
199
+ };
200
+ jest.spyOn(internals, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
201
+ jest.spyOn(internals, 'detectProtocol').mockResolvedValue('V1');
202
+ jest.spyOn(WebUsbTransport.prototype, 'getProtocolType').mockReturnValue('V1');
203
+ jest.spyOn(WebUsbTransport.prototype, 'configure').mockImplementation(() => undefined);
204
+ jest
205
+ .spyOn(WebUsbTransport.prototype, 'configureProtocolV2')
206
+ .mockImplementation(() => undefined);
207
+ const connect = jest.spyOn(WebUsbTransport.prototype, 'connect').mockImplementation(() => {
208
+ device.opened = true;
209
+ return Promise.resolve();
210
+ });
211
+ if (phase === 'acquire failure') {
212
+ connect.mockImplementationOnce(() => {
213
+ device.opened = true;
214
+ return Promise.reject(new Error('probe failed'));
215
+ });
216
+ }
217
+ jest.spyOn(WebUsbTransport.prototype, 'call').mockResolvedValue({
218
+ type: 'Features',
219
+ message: { onekey_serial_no: 'PHYSICAL_SERIAL', onekey_device_type: 'CLASSIC1S' },
220
+ });
221
+ device.close.mockRejectedValueOnce(new Error('native close failed'));
222
+ try {
223
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
224
+ expect(device.opened).toBe(true);
225
+ const active = new WebUsbTransport();
226
+ active.init({ debug: jest.fn() });
227
+ await expect(active.connectToDevice('USB_PATH', true)).rejects.toThrow(
228
+ 'USB device is already in use'
229
+ );
230
+ device.close.mockRejectedValueOnce(new Error('native close still failing'));
231
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
232
+ expect(device.opened).toBe(true);
233
+ expect(connect).toHaveBeenCalledTimes(1);
234
+ expect(device.close).toHaveBeenCalledTimes(2);
235
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toMatchObject([
236
+ { path: 'USB_PATH', serialNo: 'PHYSICAL_SERIAL' },
237
+ ]);
238
+ expect(connect).toHaveBeenCalledTimes(2);
239
+ expect(device.opened).toBe(false);
240
+ } finally {
241
+ jest.restoreAllMocks();
242
+ }
243
+ }
244
+ );
245
+
246
+ test.each([false, true])(
247
+ 'uses a read-only V1 probe only for isolated discovery (%s)',
248
+ async identityProbe => {
249
+ const probe = new WebUsbTransport(identityProbe);
250
+ const internals = probe as unknown as {
251
+ messages: object;
252
+ callProtocolV1: (
253
+ path: string,
254
+ name: string,
255
+ data: object,
256
+ options: TransportCallOptions
257
+ ) => Promise<MessageFromOneKey>;
258
+ probeProtocolV1: (path: string) => Promise<boolean>;
259
+ };
260
+ internals.messages = {};
261
+ const call = jest
262
+ .spyOn(internals, 'callProtocolV1')
263
+ .mockResolvedValue({ type: 'Features', message: {} });
264
+ await expect(internals.probeProtocolV1('USB_PATH')).resolves.toBe(true);
265
+ expect(call.mock.calls[0][1]).toBe(identityProbe ? 'GetFeatures' : 'Initialize');
266
+ }
267
+ );
268
+ });
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ interface DeviceInfo extends OneKeyDeviceInfoBase {
13
13
  protocolType?: ProtocolType;
14
14
  }
15
15
  declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
16
+ private readonly identityProbe;
16
17
  messages: ReturnType<typeof _onekeyfe_hd_transport__default.parseConfigure> | undefined;
17
18
  /** Protobuf schema for Protocol V2 transports. */
18
19
  messagesV2: ReturnType<typeof _onekeyfe_hd_transport__default.parseConfigure> | undefined;
@@ -29,6 +30,8 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
29
30
  private staleProtocolPaths;
30
31
  /** Paths currently acquired (between a successful acquire() and its release()). */
31
32
  private acquiredPaths;
33
+ private identityProbeDevices;
34
+ private identityProbeCleanupPaths;
32
35
  /**
33
36
  * The exact USBDevice object each cached protocol was probed against. The
34
37
  * browser returns the same object identity for a device as long as it stays
@@ -53,7 +56,7 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
53
56
  configurationId: number;
54
57
  endpointId: number;
55
58
  interfaceId: number;
56
- constructor();
59
+ constructor(identityProbe?: boolean);
57
60
  /**
58
61
  * Initialize WebUSB transport
59
62
  */
@@ -129,6 +132,9 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
129
132
  * Discovers interface/endpoint numbers from USB descriptors on first connection.
130
133
  */
131
134
  connectToDevice(path: string, first: boolean): Promise<undefined>;
135
+ private assertHandleAvailable;
136
+ private claimIdentityProbeHandle;
137
+ private releaseIdentityProbeHandle;
132
138
  private closeOpenDevice;
133
139
  private clearEndpointHalt;
134
140
  /**
package/dist/index.js CHANGED
@@ -56,6 +56,8 @@ const PROTOCOL_V2_PROBE_TIMEOUT = 1000;
56
56
  const EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS = 2;
57
57
  let activeWebUsbTransport;
58
58
  let usbDisconnectListenerAttached = false;
59
+ const identityProbeHandles = new WeakSet();
60
+ const openingHandles = new WeakSet();
59
61
  function registerActiveWebUsbTransport(instance, usb) {
60
62
  activeWebUsbTransport = instance;
61
63
  if (usbDisconnectListenerAttached)
@@ -72,17 +74,20 @@ function registerActiveWebUsbTransport(instance, usb) {
72
74
  });
73
75
  }
74
76
  class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
75
- constructor() {
77
+ constructor(identityProbe = false) {
76
78
  super({
77
79
  router: transport.PROTOCOL_V2_CHANNEL_USB,
78
80
  maxFrameBytes: transport.PROTOCOL_V2_FRAME_MAX_BYTES,
79
81
  logPrefix: 'ProtocolV2 WebUSB',
80
82
  });
83
+ this.identityProbe = identityProbe;
81
84
  this.deviceProtocol = new Map();
82
85
  this.confirmedDeviceProtocols = new Map();
83
86
  this.deviceProtocolHints = new Map();
84
87
  this.staleProtocolPaths = new Set();
85
88
  this.acquiredPaths = new Set();
89
+ this.identityProbeDevices = new Map();
90
+ this.identityProbeCleanupPaths = new Set();
86
91
  this.probedDeviceObjects = new Map();
87
92
  this.deviceEndpoints = new Map();
88
93
  this.name = 'WebUsbTransport';
@@ -101,7 +106,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
101
106
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'WebUSB is not supported by current browsers');
102
107
  }
103
108
  this.usb = usb;
104
- registerActiveWebUsbTransport(this, usb);
109
+ if (!this.identityProbe)
110
+ registerActiveWebUsbTransport(this, usb);
105
111
  }
106
112
  emitDeviceDisconnect(path, device) {
107
113
  var _a, _b;
@@ -150,6 +156,13 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
150
156
  }
151
157
  enumerate() {
152
158
  return __awaiter(this, void 0, void 0, function* () {
159
+ for (const path of this.identityProbeCleanupPaths) {
160
+ try {
161
+ yield this.release(path);
162
+ }
163
+ catch (_a) {
164
+ }
165
+ }
153
166
  yield this.getConnectedDevices();
154
167
  return this.deviceList;
155
168
  });
@@ -187,6 +200,10 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
187
200
  return __awaiter(this, void 0, void 0, function* () {
188
201
  if (!input.path)
189
202
  return;
203
+ if (this.identityProbe) {
204
+ yield this.getConnectedDevices();
205
+ this.claimIdentityProbeHandle(input.path, yield this.findDevice(input.path));
206
+ }
190
207
  try {
191
208
  yield this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
192
209
  yield this.closeOpenDevice(input.path);
@@ -248,7 +265,12 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
248
265
  catch (e) {
249
266
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
250
267
  this.acquiredPaths.delete(input.path);
251
- yield this.closeOpenDevice(input.path);
268
+ try {
269
+ yield this.closeOpenDevice(input.path);
270
+ }
271
+ finally {
272
+ this.releaseIdentityProbeHandle(input.path);
273
+ }
252
274
  throw e;
253
275
  }
254
276
  });
@@ -375,12 +397,22 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
375
397
  var _a;
376
398
  return __awaiter(this, void 0, void 0, function* () {
377
399
  let device = yield this.findDevice(path);
400
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
401
+ yield this.closeOpenDevice(path);
402
+ this.releaseIdentityProbeHandle(path);
403
+ this.claimIdentityProbeHandle(path, device);
404
+ }
405
+ this.assertHandleAvailable(device);
378
406
  if (!device.opened) {
407
+ openingHandles.add(device);
379
408
  try {
380
409
  yield device.open();
381
410
  }
382
411
  catch (error) {
383
- return this.throwWebUsbIoError(path, 'open', error);
412
+ return yield this.throwWebUsbIoError(path, 'open', error);
413
+ }
414
+ finally {
415
+ openingHandles.delete(device);
384
416
  }
385
417
  }
386
418
  if (first || this.deviceProtocol.get(path) !== 'V1') {
@@ -393,12 +425,22 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
393
425
  }
394
426
  yield this.getConnectedDevices();
395
427
  device = yield this.findDevice(path);
428
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
429
+ yield this.closeOpenDevice(path);
430
+ this.releaseIdentityProbeHandle(path);
431
+ this.claimIdentityProbeHandle(path, device);
432
+ }
433
+ this.assertHandleAvailable(device);
396
434
  if (!device.opened) {
435
+ openingHandles.add(device);
397
436
  try {
398
437
  yield device.open();
399
438
  }
400
439
  catch (error) {
401
- return this.throwWebUsbIoError(path, 'open', error);
440
+ return yield this.throwWebUsbIoError(path, 'open', error);
441
+ }
442
+ finally {
443
+ openingHandles.delete(device);
402
444
  }
403
445
  }
404
446
  if (first ||
@@ -418,12 +460,39 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
418
460
  yield this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
419
461
  });
420
462
  }
463
+ assertHandleAvailable(device) {
464
+ if (identityProbeHandles.has(device) &&
465
+ ![...this.identityProbeDevices.values()].includes(device)) {
466
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'USB device is already in use');
467
+ }
468
+ }
469
+ claimIdentityProbeHandle(path, device) {
470
+ if (device.opened || openingHandles.has(device) || identityProbeHandles.has(device)) {
471
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'USB device is already in use');
472
+ }
473
+ identityProbeHandles.add(device);
474
+ this.identityProbeDevices.set(path, device);
475
+ }
476
+ releaseIdentityProbeHandle(path) {
477
+ const device = this.identityProbeDevices.get(path);
478
+ if (device === null || device === void 0 ? void 0 : device.opened) {
479
+ this.identityProbeCleanupPaths.add(path);
480
+ return;
481
+ }
482
+ if (device)
483
+ identityProbeHandles.delete(device);
484
+ this.identityProbeDevices.delete(path);
485
+ this.identityProbeCleanupPaths.delete(path);
486
+ }
421
487
  closeOpenDevice(path) {
422
488
  var _a, _b, _c, _d;
423
489
  return __awaiter(this, void 0, void 0, function* () {
424
- const current = (_a = this.deviceList.find(device => device.path === path)) === null || _a === void 0 ? void 0 : _a.device;
490
+ const current = this.identityProbe
491
+ ? this.identityProbeDevices.get(path)
492
+ : (_a = this.deviceList.find(device => device.path === path)) === null || _a === void 0 ? void 0 : _a.device;
425
493
  if (!(current === null || current === void 0 ? void 0 : current.opened))
426
494
  return;
495
+ this.assertHandleAvailable(current);
427
496
  const endpoints = this.deviceEndpoints.get(path);
428
497
  const ifaceNum = (_b = endpoints === null || endpoints === void 0 ? void 0 : endpoints.interfaceNumber) !== null && _b !== void 0 ? _b : this.interfaceId;
429
498
  try {
@@ -437,6 +506,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
437
506
  }
438
507
  catch (error) {
439
508
  (_d = this.Log) === null || _d === void 0 ? void 0 : _d.debug('[WebUsbTransport] close before reconnect failed:', error);
509
+ if (this.identityProbe)
510
+ throw error;
440
511
  }
441
512
  });
442
513
  }
@@ -749,10 +820,10 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
749
820
  return false;
750
821
  }
751
822
  try {
752
- yield this.callProtocolV1(path, 'Initialize', {}, {
823
+ const response = yield this.callProtocolV1(path, this.identityProbe ? 'GetFeatures' : 'Initialize', {}, {
753
824
  timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
754
825
  });
755
- return true;
826
+ return !this.identityProbe || response.type === 'Features';
756
827
  }
757
828
  catch (error) {
758
829
  if (this.isWebUsbIoHardwareError(error))
@@ -869,8 +940,13 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
869
940
  release(path) {
870
941
  return __awaiter(this, void 0, void 0, function* () {
871
942
  this.acquiredPaths.delete(path);
872
- yield this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
873
- yield this.closeOpenDevice(path);
943
+ try {
944
+ yield this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
945
+ yield this.closeOpenDevice(path);
946
+ }
947
+ finally {
948
+ this.releaseIdentityProbeHandle(path);
949
+ }
874
950
  this.deviceEndpoints.delete(path);
875
951
  });
876
952
  }
package/dist/webusb.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface DeviceInfo extends OneKeyDeviceInfoBase {
9
9
  protocolType?: ProtocolType;
10
10
  }
11
11
  export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
12
+ private readonly identityProbe;
12
13
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
13
14
  messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
14
15
  private protocolV2SchemaSource;
@@ -17,6 +18,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
17
18
  private deviceProtocolHints;
18
19
  private staleProtocolPaths;
19
20
  private acquiredPaths;
21
+ private identityProbeDevices;
22
+ private identityProbeCleanupPaths;
20
23
  private probedDeviceObjects;
21
24
  private deviceEndpoints;
22
25
  name: string;
@@ -29,7 +32,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
29
32
  configurationId: number;
30
33
  endpointId: number;
31
34
  interfaceId: number;
32
- constructor();
35
+ constructor(identityProbe?: boolean);
33
36
  init(logger: any, emitter?: EventEmitter): void;
34
37
  emitDeviceDisconnect(path: string, device?: USBDevice): void;
35
38
  markProtocolStale(path: string): void;
@@ -47,6 +50,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
47
50
  connect(path: string, first: boolean): Promise<undefined>;
48
51
  private discoverEndpoints;
49
52
  connectToDevice(path: string, first: boolean): Promise<undefined>;
53
+ private assertHandleAvailable;
54
+ private claimIdentityProbeHandle;
55
+ private releaseIdentityProbeHandle;
50
56
  private closeOpenDevice;
51
57
  private clearEndpointHalt;
52
58
  private assertAcquired;
@@ -1 +1 @@
1
- {"version":3,"file":"webusb.d.ts","sourceRoot":"","sources":["../src/webusb.ts"],"names":[],"mappings":";;AACA,OAAO,SAAS,EAAE,EAQhB,0BAA0B,EAG3B,MAAM,wBAAwB,CAAC;AAahC,OAAO,KAAK,YAAY,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACpB,YAAY,EACZ,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,EACrB,MAAM,wBAAwB,CAAC;AAuBhC,MAAM,WAAW,UAAW,SAAQ,oBAAoB;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,CAAC;IAClB,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B;AAyCD,MAAM,CAAC,OAAO,OAAO,eAAgB,SAAQ,0BAA0B,CAAC,MAAM,CAAC;IAC7E,QAAQ,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,cAAc,CAAC,GAAG,SAAS,CAAC;IAGlE,UAAU,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,cAAc,CAAC,GAAG,SAAS,CAAC;IAEpE,OAAO,CAAC,sBAAsB,CAAqB;IAGnD,OAAO,CAAC,cAAc,CAAwC;IAG9D,OAAO,CAAC,wBAAwB,CAAwC;IAExE,OAAO,CAAC,mBAAmB,CAAwC;IAMnE,OAAO,CAAC,kBAAkB,CAA0B;IAGpD,OAAO,CAAC,aAAa,CAA0B;IAS/C,OAAO,CAAC,mBAAmB,CAAqC;IAGhE,OAAO,CAAC,eAAe,CAA2C;IAElE,IAAI,SAAqB;IAEzB,OAAO,UAAS;IAEhB,UAAU,UAAS;IAEnB,GAAG,CAAC,EAAE,GAAG,CAAC;IAEV,GAAG,CAAC,EAAE,GAAG,CAAC;IAEV,OAAO,CAAC,EAAE,YAAY,CAAC;IAMvB,UAAU,EAAE,KAAK,CAAC,UAAU,CAAC,CAAM;IAEnC,eAAe,SAAoB;IAEnC,UAAU,SAAe;IAEzB,WAAW,SAAgB;;IAa3B,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,YAAY;IAmBxC,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS;IAkBrD,iBAAiB,CAAC,IAAI,EAAE,MAAM;IAQ9B,SAAS,CAAC,UAAU,EAAE,GAAG;IASzB,mBAAmB,CAAC,UAAU,EAAE,GAAG;IAsB7B,kBAAkB;IAmBlB,SAAS;IAQT,mBAAmB;IAmCnB,OAAO,CAAC,KAAK,EAAE,YAAY;IA+FjC,OAAO,CAAC,2BAA2B;IAOnC,OAAO,CAAC,+BAA+B;IAOvC,OAAO,CAAC,4BAA4B;YAItB,cAAc;IAmEtB,UAAU,CAAC,IAAI,EAAE,MAAM;IAwBvB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO;IAqB1C,OAAO,CAAC,iBAAiB;IAiCnB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO;YAgDpC,eAAe;YAkBf,iBAAiB;IAsB/B,OAAO,CAAC,cAAc;IAMhB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IASvE,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,uBAAuB;YASjB,uBAAuB;YAWvB,kBAAkB;IAwChC,OAAO,CAAC,+BAA+B;IAIvC,OAAO,CAAC,wBAAwB;YAelB,yBAAyB;IAsCvC,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,aAAa;YASP,oBAAoB;YA2BpB,eAAe;YAoBf,mBAAmB;YA2CnB,cAAc;YAWd,yBAAyB;YAKzB,yBAAyB;YAMzB,uBAAuB;YA0CvB,eAAe;YAqBf,eAAe;IAiBvB,IAAI,CACR,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,CAAC,EAAE,oBAAoB;YA2BlB,cAAc;YAkCd,cAAc;IAkBtB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM;IAgD5C,OAAO,CAAC,IAAI,EAAE,MAAM;IAY1B,SAAS,CAAC,uBAAuB,IAAI,iBAAiB;IAUtD,SAAS,CAAC,sBAAsB;cAIhB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,qBAAqB,GAC9B,OAAO,CAAC,IAAI,CAAC;cAIA,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,qBAAqB,GAC9B,OAAO,CAAC,UAAU,CAAC;cASN,4BAA4B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI1F,SAAS,CAAC,8BAA8B,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;IAKrE,SAAS,CAAC,+BAA+B,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,KAAK;IAWjF,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;CAGxD"}
1
+ {"version":3,"file":"webusb.d.ts","sourceRoot":"","sources":["../src/webusb.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-web-device",
3
- "version": "1.2.8-alpha.5",
3
+ "version": "1.2.8-alpha.51",
4
4
  "author": "OneKey",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -21,13 +21,13 @@
21
21
  "lint:fix": "eslint . --fix"
22
22
  },
23
23
  "dependencies": {
24
- "@onekeyfe/hd-shared": "1.2.8-alpha.5",
25
- "@onekeyfe/hd-transport": "1.2.8-alpha.5"
24
+ "@onekeyfe/hd-shared": "1.2.8-alpha.51",
25
+ "@onekeyfe/hd-transport": "1.2.8-alpha.51"
26
26
  },
27
27
  "devDependencies": {
28
- "@onekeyfe/hd-transport-electron": "1.2.8-alpha.5",
28
+ "@onekeyfe/hd-transport-electron": "1.2.8-alpha.51",
29
29
  "@types/w3c-web-usb": "^1.0.6",
30
30
  "@types/web-bluetooth": "^0.0.17"
31
31
  },
32
- "gitHead": "c1f24dc0a59d0c02fe05594a9c4ae7652ec9cc32"
32
+ "gitHead": "0ccbc08199c62e96f6084e864e558055100b66ed"
33
33
  }
package/src/webusb.ts CHANGED
@@ -82,6 +82,10 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
82
82
  */
83
83
  let activeWebUsbTransport: WebUsbTransport | undefined;
84
84
  let usbDisconnectListenerAttached = false;
85
+ // Only isolated discovery owns these handles. Normal calls must never close or
86
+ // open them, including while native open() is still pending.
87
+ const identityProbeHandles = new WeakSet<USBDevice>();
88
+ const openingHandles = new WeakSet<USBDevice>();
85
89
 
86
90
  function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
87
91
  activeWebUsbTransport = instance;
@@ -124,6 +128,10 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
124
128
  /** Paths currently acquired (between a successful acquire() and its release()). */
125
129
  private acquiredPaths: Set<string> = new Set();
126
130
 
131
+ private identityProbeDevices = new Map<string, USBDevice>();
132
+
133
+ private identityProbeCleanupPaths = new Set<string>();
134
+
127
135
  /**
128
136
  * The exact USBDevice object each cached protocol was probed against. The
129
137
  * browser returns the same object identity for a device as long as it stays
@@ -160,7 +168,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
160
168
 
161
169
  interfaceId = INTERFACE_ID;
162
170
 
163
- constructor() {
171
+ constructor(private readonly identityProbe = false) {
164
172
  super({
165
173
  router: PROTOCOL_V2_CHANNEL_USB,
166
174
  maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
@@ -183,7 +191,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
183
191
  );
184
192
  }
185
193
  this.usb = usb;
186
- registerActiveWebUsbTransport(this, usb);
194
+ if (!this.identityProbe) registerActiveWebUsbTransport(this, usb);
187
195
  }
188
196
 
189
197
  /**
@@ -267,6 +275,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
267
275
  * It does NOT prompt the user to select a device
268
276
  */
269
277
  async enumerate() {
278
+ // Retry only our failed cleanup before Core skips already-open USB handles.
279
+ for (const path of this.identityProbeCleanupPaths) {
280
+ try {
281
+ await this.release(path);
282
+ } catch {
283
+ // Keep ownership while native close still fails; retry on the next probe.
284
+ }
285
+ }
270
286
  await this.getConnectedDevices();
271
287
  return this.deviceList;
272
288
  }
@@ -311,6 +327,10 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
311
327
  */
312
328
  async acquire(input: AcquireInput) {
313
329
  if (!input.path) return;
330
+ if (this.identityProbe) {
331
+ await this.getConnectedDevices();
332
+ this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
333
+ }
314
334
  try {
315
335
  await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
316
336
  await this.closeOpenDevice(input.path);
@@ -394,7 +414,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
394
414
  } catch (e) {
395
415
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
396
416
  this.acquiredPaths.delete(input.path);
397
- await this.closeOpenDevice(input.path);
417
+ try {
418
+ await this.closeOpenDevice(input.path);
419
+ } finally {
420
+ this.releaseIdentityProbeHandle(input.path);
421
+ }
398
422
  throw e;
399
423
  }
400
424
  }
@@ -569,11 +593,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
569
593
  */
570
594
  async connectToDevice(path: string, first: boolean) {
571
595
  let device: USBDevice = await this.findDevice(path);
596
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
597
+ await this.closeOpenDevice(path);
598
+ this.releaseIdentityProbeHandle(path);
599
+ this.claimIdentityProbeHandle(path, device);
600
+ }
601
+ this.assertHandleAvailable(device);
572
602
  if (!device.opened) {
603
+ openingHandles.add(device);
573
604
  try {
574
605
  await device.open();
575
606
  } catch (error) {
576
- return this.throwWebUsbIoError(path, 'open', error);
607
+ return await this.throwWebUsbIoError(path, 'open', error);
608
+ } finally {
609
+ openingHandles.delete(device);
577
610
  }
578
611
  }
579
612
  // A V1 packet retry continues an in-flight exchange. Preserve its endpoint
@@ -587,11 +620,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
587
620
  }
588
621
  await this.getConnectedDevices();
589
622
  device = await this.findDevice(path);
623
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
624
+ await this.closeOpenDevice(path);
625
+ this.releaseIdentityProbeHandle(path);
626
+ this.claimIdentityProbeHandle(path, device);
627
+ }
628
+ this.assertHandleAvailable(device);
590
629
  if (!device.opened) {
630
+ openingHandles.add(device);
591
631
  try {
592
632
  await device.open();
593
633
  } catch (error) {
594
- return this.throwWebUsbIoError(path, 'open', error);
634
+ return await this.throwWebUsbIoError(path, 'open', error);
635
+ } finally {
636
+ openingHandles.delete(device);
595
637
  }
596
638
  }
597
639
 
@@ -615,9 +657,41 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
615
657
  await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
616
658
  }
617
659
 
660
+ private assertHandleAvailable(device: USBDevice) {
661
+ if (
662
+ identityProbeHandles.has(device) &&
663
+ ![...this.identityProbeDevices.values()].includes(device)
664
+ ) {
665
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'USB device is already in use');
666
+ }
667
+ }
668
+
669
+ private claimIdentityProbeHandle(path: string, device: USBDevice) {
670
+ if (device.opened || openingHandles.has(device) || identityProbeHandles.has(device)) {
671
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'USB device is already in use');
672
+ }
673
+ identityProbeHandles.add(device);
674
+ this.identityProbeDevices.set(path, device);
675
+ }
676
+
677
+ private releaseIdentityProbeHandle(path: string) {
678
+ const device = this.identityProbeDevices.get(path);
679
+ // A failed native close must not make an open probe handle available to other callers.
680
+ if (device?.opened) {
681
+ this.identityProbeCleanupPaths.add(path);
682
+ return;
683
+ }
684
+ if (device) identityProbeHandles.delete(device);
685
+ this.identityProbeDevices.delete(path);
686
+ this.identityProbeCleanupPaths.delete(path);
687
+ }
688
+
618
689
  private async closeOpenDevice(path: string) {
619
- const current = this.deviceList.find(device => device.path === path)?.device;
690
+ const current = this.identityProbe
691
+ ? this.identityProbeDevices.get(path)
692
+ : this.deviceList.find(device => device.path === path)?.device;
620
693
  if (!current?.opened) return;
694
+ this.assertHandleAvailable(current);
621
695
 
622
696
  const endpoints = this.deviceEndpoints.get(path);
623
697
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
@@ -630,6 +704,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
630
704
  await current.close();
631
705
  } catch (error) {
632
706
  this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
707
+ if (this.identityProbe) throw error;
633
708
  }
634
709
  }
635
710
 
@@ -984,15 +1059,15 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
984
1059
  }
985
1060
 
986
1061
  try {
987
- await this.callProtocolV1(
1062
+ const response = await this.callProtocolV1(
988
1063
  path,
989
- 'Initialize',
1064
+ this.identityProbe ? 'GetFeatures' : 'Initialize',
990
1065
  {},
991
1066
  {
992
1067
  timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
993
1068
  }
994
1069
  );
995
- return true;
1070
+ return !this.identityProbe || response.type === 'Features';
996
1071
  } catch (error) {
997
1072
  if (this.isWebUsbIoHardwareError(error)) throw error;
998
1073
  return false;
@@ -1149,8 +1224,12 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1149
1224
  */
1150
1225
  async release(path: string) {
1151
1226
  this.acquiredPaths.delete(path);
1152
- await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1153
- await this.closeOpenDevice(path);
1227
+ try {
1228
+ await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1229
+ await this.closeOpenDevice(path);
1230
+ } finally {
1231
+ this.releaseIdentityProbeHandle(path);
1232
+ }
1154
1233
  // Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
1155
1234
  // physical-device property, so the next acquire can skip the wire-level probe.
1156
1235
  // V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link