@onekeyfe/hd-transport-web-device 1.2.8-alpha.15 → 1.2.8-alpha.17

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,6 +1,39 @@
1
+ import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
2
+
1
3
  import WebUsbTransport from '../src/webusb';
4
+ import { probeUsbDeviceIdentities } from '@onekeyfe/hd-common-connect-sdk/src';
5
+ import Core from '@onekeyfe/hd-core/src/core';
6
+ import TransportManager from '@onekeyfe/hd-core/src/data-manager/TransportManager';
7
+ import { Device } from '@onekeyfe/hd-core/src/device/Device';
8
+
9
+ import type { MessageFromOneKey, Transport, TransportCallOptions } from '@onekeyfe/hd-transport';
10
+
11
+ jest.mock('@onekeyfe/hd-core', () => jest.requireActual('../../core/src'));
12
+ jest.mock('../../core/src/data/config', () => ({
13
+ getSDKVersion: () => '1.0.0-test',
14
+ DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0-test/',
15
+ }));
16
+ jest.mock('@onekeyfe/hd-transport-web-device', () => ({
17
+ WebUsbTransport: jest.requireActual('../src/webusb').default,
18
+ }));
19
+ jest.mock('@onekeyfe/hd-transport-http', () => ({ __esModule: true, default: jest.fn() }));
20
+ jest.mock('@onekeyfe/hd-transport-lowlevel', () => ({}));
21
+ jest.mock('@onekeyfe/hd-transport-emulator', () => ({}));
2
22
 
3
- import type { MessageFromOneKey, TransportCallOptions } from '@onekeyfe/hd-transport';
23
+ type PacketIoTransport = {
24
+ rotateProtocolV2UsbGeneration: () => Promise<void>;
25
+ detectProtocol: () => Promise<'V1' | 'V2'>;
26
+ transferInWithRetry: (path: string, length: number) => Promise<DataView>;
27
+ transferInOnce: (path: string, length: number) => Promise<DataView>;
28
+ transferOutWithRetry: (path: string, packet: Uint8Array) => Promise<void>;
29
+ transferOutOnce: (path: string, packet: Uint8Array) => Promise<void>;
30
+ reconnectForPacketIoRetry: (
31
+ path: string,
32
+ direction: 'in' | 'out',
33
+ attempt: number,
34
+ error: unknown
35
+ ) => Promise<void>;
36
+ };
4
37
 
5
38
  describe('WebUSB identity probe ownership', () => {
6
39
  let originalNavigator: PropertyDescriptor | undefined;
@@ -34,6 +67,251 @@ describe('WebUSB identity probe ownership', () => {
34
67
  else delete (globalThis as { navigator?: Navigator }).navigator;
35
68
  });
36
69
 
70
+ function createAcquiredTransport(protocol: 'V1' | 'V2' = 'V1') {
71
+ const active = new WebUsbTransport();
72
+ active.init({ debug: jest.fn() });
73
+ jest
74
+ .spyOn(active as unknown as PacketIoTransport, 'detectProtocol')
75
+ .mockResolvedValue(protocol);
76
+ jest.spyOn(active, 'connect').mockImplementation(() => {
77
+ device.opened = true;
78
+ return Promise.resolve();
79
+ });
80
+ return active;
81
+ }
82
+
83
+ test.each(['V1', 'V2'] as const)(
84
+ 'Core disposal releases a retained %s USB session for a new transport',
85
+ async protocol => {
86
+ const active = createAcquiredTransport(protocol);
87
+ const next = createAcquiredTransport(protocol);
88
+ const selected = new Device({
89
+ path: 'USB_PATH',
90
+ id: 'USB_PATH',
91
+ name: 'Mock device',
92
+ commType: 'webusb',
93
+ debug: false,
94
+ });
95
+ selected.keepSession = true;
96
+ selected.mainId = 'USB_PATH';
97
+ jest.spyOn(selected, 'isUsedHere').mockReturnValue(true);
98
+ const release = jest.spyOn(active, 'release');
99
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue(active as unknown as Transport);
100
+ const core = new Core();
101
+ try {
102
+ await active.acquire({ path: 'USB_PATH' });
103
+ await selected.release();
104
+ expect(release).not.toHaveBeenCalled();
105
+ expect(device.opened).toBe(true);
106
+ await core.dispose();
107
+ await core.dispose();
108
+ expect(device.opened).toBe(false);
109
+ expect(device.close).toHaveBeenCalledTimes(1);
110
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
111
+ } finally {
112
+ await active.release('USB_PATH');
113
+ await next.release('USB_PATH');
114
+ jest.restoreAllMocks();
115
+ }
116
+ }
117
+ );
118
+
119
+ test('stop waits for a pending native open and closes its captured handle', async () => {
120
+ const entered = createDeferred<void>();
121
+ const opening = createDeferred<void>();
122
+ const native = Object.assign(device, {
123
+ open: jest.fn(async () => {
124
+ entered.resolve();
125
+ await opening.promise;
126
+ device.opened = true;
127
+ }),
128
+ reset: jest.fn(),
129
+ configurations: [],
130
+ configuration: { configurationValue: 1 },
131
+ selectConfiguration: jest.fn(),
132
+ claimInterface: jest.fn(),
133
+ clearHalt: jest.fn(),
134
+ });
135
+ const active = new WebUsbTransport();
136
+ active.init({ debug: jest.fn() });
137
+ const connecting = active.connectToDevice('USB_PATH', true);
138
+ const result = expect(connecting).rejects.toThrow('WebUSB transport is stopped');
139
+ try {
140
+ await entered.promise;
141
+ let stopped = false;
142
+ const stopping = active.stop().then(() => {
143
+ stopped = true;
144
+ });
145
+ await Promise.resolve();
146
+ expect(stopped).toBe(false);
147
+ opening.resolve();
148
+ await result;
149
+ await stopping;
150
+ expect(native.reset).not.toHaveBeenCalled();
151
+ expect(native.claimInterface).not.toHaveBeenCalled();
152
+ expect(device.opened).toBe(false);
153
+ const next = createAcquiredTransport();
154
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
155
+ await next.release('USB_PATH');
156
+ } finally {
157
+ opening.resolve();
158
+ await connecting.catch(() => undefined);
159
+ await active.release('USB_PATH');
160
+ jest.restoreAllMocks();
161
+ }
162
+ });
163
+
164
+ test.each(['read', 'read retry', 'write', 'write retry'])(
165
+ 'stop rejects a late native %s result without touching the new owner',
166
+ async operation => {
167
+ const entered = createDeferred<void>();
168
+ const packet = createDeferred<{ status: 'ok'; data: DataView; bytesWritten: number }>();
169
+ Object.assign(device, {
170
+ transferIn: jest.fn(() => {
171
+ entered.resolve();
172
+ return packet.promise;
173
+ }),
174
+ transferOut: jest.fn(() => {
175
+ entered.resolve();
176
+ return packet.promise;
177
+ }),
178
+ });
179
+ const active = createAcquiredTransport();
180
+ const next = createAcquiredTransport();
181
+ await active.acquire({ path: 'USB_PATH' });
182
+ const state = active as unknown as PacketIoTransport;
183
+ let call: Promise<unknown>;
184
+ if (operation === 'read') call = state.transferInOnce('USB_PATH', 64);
185
+ else if (operation === 'read retry') call = state.transferInWithRetry('USB_PATH', 64);
186
+ else if (operation === 'write') call = state.transferOutOnce('USB_PATH', new Uint8Array([1]));
187
+ else call = state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
188
+ const result = expect(call).rejects.toThrow('WebUSB transport is stopped');
189
+ try {
190
+ await entered.promise;
191
+ await active.stop();
192
+ await next.acquire({ path: 'USB_PATH' });
193
+ packet.resolve({ status: 'ok', data: new DataView(new ArrayBuffer(64)), bytesWritten: 1 });
194
+ await result;
195
+ expect(device.close).toHaveBeenCalledTimes(1);
196
+ expect(device.opened).toBe(true);
197
+ } finally {
198
+ packet.resolve({ status: 'ok', data: new DataView(new ArrayBuffer(64)), bytesWritten: 1 });
199
+ await call.catch(() => undefined);
200
+ await active.release('USB_PATH');
201
+ await next.release('USB_PATH');
202
+ jest.restoreAllMocks();
203
+ }
204
+ }
205
+ );
206
+
207
+ test.each(['in', 'out'] as const)(
208
+ 'stop prevents a delayed transfer%s retry from closing or reopening the new owner',
209
+ async direction => {
210
+ const entered = createDeferred<void>();
211
+ const native = Object.assign(device, {
212
+ transferIn: jest.fn().mockRejectedValue(new Error('transferIn NetworkError')),
213
+ transferOut: jest.fn().mockRejectedValue(new Error('transferOut NetworkError')),
214
+ });
215
+ const active = createAcquiredTransport();
216
+ const next = createAcquiredTransport();
217
+ await active.acquire({ path: 'USB_PATH' });
218
+ const state = active as unknown as PacketIoTransport;
219
+ const retry = state.reconnectForPacketIoRetry.bind(active);
220
+ jest.spyOn(state, 'reconnectForPacketIoRetry').mockImplementation((...args) => {
221
+ entered.resolve();
222
+ return retry(...args);
223
+ });
224
+ const call =
225
+ direction === 'in'
226
+ ? state.transferInWithRetry('USB_PATH', 64)
227
+ : state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
228
+ const result = expect(call).rejects.toThrow('WebUSB transport is stopped');
229
+ try {
230
+ await entered.promise;
231
+ await active.stop();
232
+ await next.acquire({ path: 'USB_PATH' });
233
+ await result;
234
+ expect(native[direction === 'in' ? 'transferIn' : 'transferOut']).toHaveBeenCalledTimes(1);
235
+ expect(device.close).toHaveBeenCalledTimes(1);
236
+ expect(device.opened).toBe(true);
237
+ } finally {
238
+ await call.catch(() => undefined);
239
+ await active.release('USB_PATH');
240
+ await next.release('USB_PATH');
241
+ jest.restoreAllMocks();
242
+ }
243
+ }
244
+ );
245
+
246
+ test('failed stop keeps ownership until a later discovery confirms native closure', async () => {
247
+ const active = createAcquiredTransport();
248
+ const next = createAcquiredTransport();
249
+ const failure = new Error('native close failed');
250
+ await active.acquire({ path: 'USB_PATH' });
251
+ device.close.mockRejectedValueOnce(failure).mockRejectedValueOnce(failure);
252
+ try {
253
+ await expect(active.stop()).rejects.toBe(failure);
254
+ await next.getConnectedDevices();
255
+ await expect(next.acquire({ path: 'USB_PATH' })).rejects.toThrow(
256
+ 'USB device is already in use'
257
+ );
258
+ expect(device.opened).toBe(true);
259
+ await next.getConnectedDevices();
260
+ expect(device.opened).toBe(false);
261
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
262
+ expect(device.close).toHaveBeenCalledTimes(3);
263
+ } finally {
264
+ await active.release('USB_PATH');
265
+ await next.release('USB_PATH');
266
+ jest.restoreAllMocks();
267
+ }
268
+ });
269
+
270
+ test('stop closes captured handles across paths without closing re-enumerated or foreign handles', async () => {
271
+ const other = {
272
+ ...device,
273
+ serialNumber: 'OTHER_PATH',
274
+ close: jest.fn(() => {
275
+ other.opened = false;
276
+ return Promise.resolve();
277
+ }),
278
+ };
279
+ const foreign = { ...device, serialNumber: 'FOREIGN_PATH', close: jest.fn() };
280
+ usb.getDevices.mockResolvedValue([device, other, foreign]);
281
+ const active = createAcquiredTransport();
282
+ jest.spyOn(active, 'connect').mockImplementation(path => {
283
+ (path === 'USB_PATH' ? device : other).opened = true;
284
+ return Promise.resolve();
285
+ });
286
+ const current = createAcquiredTransport();
287
+ jest.spyOn(current, 'connect').mockImplementation(() => {
288
+ foreign.opened = true;
289
+ return Promise.resolve();
290
+ });
291
+ const currentDisconnect = jest.spyOn(current, 'markProtocolStale');
292
+ try {
293
+ await active.acquire({ path: 'USB_PATH' });
294
+ await active.acquire({ path: 'OTHER_PATH' });
295
+ await current.acquire({ path: 'FOREIGN_PATH' });
296
+ const replacement = { ...device, opened: false, close: jest.fn() };
297
+ usb.getDevices.mockResolvedValue([replacement, foreign]);
298
+ await active.getConnectedDevices();
299
+ await active.stop();
300
+ expect(device.opened).toBe(false);
301
+ expect(other.opened).toBe(false);
302
+ expect(replacement.close).not.toHaveBeenCalled();
303
+ expect(foreign.close).not.toHaveBeenCalled();
304
+ expect(foreign.opened).toBe(true);
305
+ disconnectListener({ device: replacement });
306
+ expect(currentDisconnect).toHaveBeenCalledWith('USB_PATH');
307
+ } finally {
308
+ await active.release('USB_PATH');
309
+ await active.release('OTHER_PATH');
310
+ await current.release('FOREIGN_PATH');
311
+ jest.restoreAllMocks();
312
+ }
313
+ });
314
+
37
315
  test('does not close a handle already opened by another operation', async () => {
38
316
  const probe = new WebUsbTransport(true);
39
317
  probe.init({ debug: jest.fn() });
@@ -80,6 +358,7 @@ describe('WebUSB identity probe ownership', () => {
80
358
  expect(device.close).not.toHaveBeenCalled();
81
359
  finishOpen();
82
360
  await connecting;
361
+ await active.release('USB_PATH');
83
362
  });
84
363
 
85
364
  test('a normal connection cannot close an isolated probe handle and release closes the captured handle after re-enumeration', async () => {
@@ -105,8 +384,10 @@ describe('WebUSB identity probe ownership', () => {
105
384
  const replacement = { ...device, close: jest.fn() };
106
385
  usb.getDevices.mockResolvedValue([replacement]);
107
386
  await probe.getConnectedDevices();
108
- await probe.release('USB_PATH');
109
- expect(device.close).toHaveBeenCalledTimes(1);
387
+ device.close.mockRejectedValueOnce(new Error('native close failed'));
388
+ await expect(probe.release('USB_PATH')).rejects.toThrow('native close failed');
389
+ await probe.enumerate();
390
+ expect(device.close).toHaveBeenCalledTimes(2);
110
391
  expect(replacement.close).not.toHaveBeenCalled();
111
392
  });
112
393
 
@@ -175,6 +456,292 @@ describe('WebUSB identity probe ownership', () => {
175
456
  }
176
457
  );
177
458
 
459
+ test.each(['release', 'acquire failure'])(
460
+ 'the public probe retries failed %s cleanup on its next call',
461
+ async phase => {
462
+ const internals = WebUsbTransport.prototype as unknown as {
463
+ rotateProtocolV2UsbGeneration: () => Promise<void>;
464
+ detectProtocol: () => Promise<'V1'>;
465
+ };
466
+ jest.spyOn(internals, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
467
+ jest.spyOn(internals, 'detectProtocol').mockResolvedValue('V1');
468
+ jest.spyOn(WebUsbTransport.prototype, 'getProtocolType').mockReturnValue('V1');
469
+ jest.spyOn(WebUsbTransport.prototype, 'configure').mockImplementation(() => undefined);
470
+ jest
471
+ .spyOn(WebUsbTransport.prototype, 'configureProtocolV2')
472
+ .mockImplementation(() => undefined);
473
+ const connect = jest.spyOn(WebUsbTransport.prototype, 'connect').mockImplementation(() => {
474
+ device.opened = true;
475
+ return Promise.resolve();
476
+ });
477
+ if (phase === 'acquire failure') {
478
+ connect.mockImplementationOnce(() => {
479
+ device.opened = true;
480
+ return Promise.reject(new Error('probe failed'));
481
+ });
482
+ }
483
+ jest.spyOn(WebUsbTransport.prototype, 'call').mockResolvedValue({
484
+ type: 'Features',
485
+ message: { onekey_serial_no: 'PHYSICAL_SERIAL', onekey_device_type: 'CLASSIC1S' },
486
+ });
487
+ device.close.mockRejectedValueOnce(new Error('native close failed'));
488
+ try {
489
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
490
+ expect(device.opened).toBe(true);
491
+ const active = new WebUsbTransport();
492
+ active.init({ debug: jest.fn() });
493
+ await expect(active.connectToDevice('USB_PATH', true)).rejects.toThrow(
494
+ 'USB device is already in use'
495
+ );
496
+ device.close.mockRejectedValueOnce(new Error('native close still failing'));
497
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
498
+ expect(device.opened).toBe(true);
499
+ expect(connect).toHaveBeenCalledTimes(1);
500
+ expect(device.close).toHaveBeenCalledTimes(2);
501
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toMatchObject([
502
+ { path: 'USB_PATH', serialNo: 'PHYSICAL_SERIAL' },
503
+ ]);
504
+ expect(connect).toHaveBeenCalledTimes(2);
505
+ expect(device.opened).toBe(false);
506
+ } finally {
507
+ jest.restoreAllMocks();
508
+ }
509
+ }
510
+ );
511
+
512
+ test.each(['in', 'out'] as const)(
513
+ 'the public probe cannot take over a first acquire while transfer%s recovery has closed the handle',
514
+ async direction => {
515
+ const closed = createDeferred<void>();
516
+ const resume = createDeferred<void>();
517
+ Object.assign(device, {
518
+ open: jest.fn(() => {
519
+ device.opened = true;
520
+ return Promise.resolve();
521
+ }),
522
+ releaseInterface: jest.fn(),
523
+ reset: jest.fn(),
524
+ configurations: [],
525
+ configuration: { configurationValue: 1 },
526
+ selectConfiguration: jest.fn(),
527
+ claimInterface: jest.fn(),
528
+ clearHalt: jest.fn(),
529
+ transferIn: jest
530
+ .fn()
531
+ .mockResolvedValue({ status: 'ok', data: new DataView(new ArrayBuffer(64)) }),
532
+ transferOut: jest.fn().mockResolvedValue({ status: 'ok' }),
533
+ });
534
+ const native = device as typeof device & { transferIn: jest.Mock; transferOut: jest.Mock };
535
+ native[direction === 'in' ? 'transferIn' : 'transferOut'].mockRejectedValueOnce(
536
+ new Error(`transfer${direction} NetworkError`)
537
+ );
538
+ device.close.mockImplementationOnce(async () => {
539
+ device.opened = false;
540
+ closed.resolve();
541
+ await resume.promise;
542
+ });
543
+ const active = new WebUsbTransport();
544
+ active.init({ debug: jest.fn() });
545
+ const internals = active as unknown as PacketIoTransport;
546
+ jest
547
+ .spyOn(
548
+ WebUsbTransport.prototype as unknown as PacketIoTransport,
549
+ 'rotateProtocolV2UsbGeneration'
550
+ )
551
+ .mockResolvedValue(undefined);
552
+ jest
553
+ .spyOn(WebUsbTransport.prototype as unknown as PacketIoTransport, 'detectProtocol')
554
+ .mockImplementation(async function (this: WebUsbTransport) {
555
+ if (this !== active) return 'V1';
556
+ if (direction === 'in') await internals.transferInWithRetry('USB_PATH', 64);
557
+ else await internals.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
558
+ return 'V1';
559
+ });
560
+ jest.spyOn(WebUsbTransport.prototype, 'getProtocolType').mockReturnValue('V1');
561
+ jest.spyOn(WebUsbTransport.prototype, 'configure').mockImplementation(() => undefined);
562
+ jest
563
+ .spyOn(WebUsbTransport.prototype, 'configureProtocolV2')
564
+ .mockImplementation(() => undefined);
565
+ jest
566
+ .spyOn(WebUsbTransport.prototype, 'connect')
567
+ .mockImplementation(async function (this: WebUsbTransport, path, first) {
568
+ if (this === active && !first) return this.connectToDevice(path, first);
569
+ device.opened = true;
570
+ });
571
+ jest.spyOn(WebUsbTransport.prototype, 'call').mockResolvedValue({
572
+ type: 'Features',
573
+ message: { onekey_serial_no: 'PHYSICAL_SERIAL', onekey_device_type: 'CLASSIC1S' },
574
+ });
575
+ try {
576
+ const acquiring = active.acquire({ path: 'USB_PATH' });
577
+ try {
578
+ await closed.promise;
579
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
580
+ resume.resolve();
581
+ await expect(acquiring).resolves.toBe('USB_PATH');
582
+ } finally {
583
+ resume.resolve();
584
+ await acquiring.catch(() => undefined);
585
+ await active.release('USB_PATH');
586
+ }
587
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toMatchObject([
588
+ { serialNo: 'PHYSICAL_SERIAL' },
589
+ ]);
590
+ } finally {
591
+ jest.restoreAllMocks();
592
+ }
593
+ }
594
+ );
595
+
596
+ test('releases ownership after a failed normal acquire', async () => {
597
+ const active = new WebUsbTransport();
598
+ const probe = new WebUsbTransport(true);
599
+ active.init({ debug: jest.fn() });
600
+ probe.init({ debug: jest.fn() });
601
+ const state = active as unknown as PacketIoTransport;
602
+ jest.spyOn(state, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
603
+ jest.spyOn(active, 'connect').mockImplementation(() => {
604
+ device.opened = true;
605
+ return Promise.resolve();
606
+ });
607
+ jest.spyOn(state, 'detectProtocol').mockRejectedValue(new Error('protocol failed'));
608
+ await expect(active.acquire({ path: 'USB_PATH' })).rejects.toThrow('protocol failed');
609
+ expect(device.opened).toBe(false);
610
+ const probeState = probe as unknown as PacketIoTransport;
611
+ jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
612
+ jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
613
+ jest.spyOn(probe, 'connect').mockImplementation(() => {
614
+ device.opened = true;
615
+ return Promise.resolve();
616
+ });
617
+ try {
618
+ await expect(probe.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
619
+ } finally {
620
+ await probe.release('USB_PATH');
621
+ }
622
+ });
623
+
624
+ test('releasing one path preserves another closed handle owned by the same transport', async () => {
625
+ const other = {
626
+ ...device,
627
+ serialNumber: 'OTHER_PATH',
628
+ opened: false,
629
+ close: jest.fn(() => {
630
+ other.opened = false;
631
+ return Promise.resolve();
632
+ }),
633
+ };
634
+ usb.getDevices.mockResolvedValue([device, other]);
635
+ const active = new WebUsbTransport();
636
+ active.init({ debug: jest.fn() });
637
+ const state = active as unknown as PacketIoTransport;
638
+ jest.spyOn(state, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
639
+ jest.spyOn(state, 'detectProtocol').mockResolvedValue('V1');
640
+ jest.spyOn(active, 'connect').mockImplementation(path => {
641
+ const target = path === 'USB_PATH' ? device : other;
642
+ target.opened = true;
643
+ return Promise.resolve();
644
+ });
645
+ await active.acquire({ path: 'USB_PATH' });
646
+ await active.acquire({ path: 'OTHER_PATH' });
647
+ await active.release('USB_PATH');
648
+ other.opened = false;
649
+ const probe = new WebUsbTransport(true);
650
+ probe.init({ debug: jest.fn() });
651
+ const probeState = probe as unknown as PacketIoTransport;
652
+ jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
653
+ jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
654
+ jest.spyOn(probe, 'connect').mockImplementation(path => {
655
+ const target = path === 'USB_PATH' ? device : other;
656
+ target.opened = true;
657
+ return Promise.resolve();
658
+ });
659
+ try {
660
+ await expect(probe.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
661
+ await expect(probe.acquire({ path: 'OTHER_PATH' })).rejects.toThrow(
662
+ 'USB device is already in use'
663
+ );
664
+ } finally {
665
+ await active.release('OTHER_PATH');
666
+ await probe.release('USB_PATH');
667
+ }
668
+ });
669
+
670
+ test.each(['read', 'read retry', 'write', 'write retry', 'retry cleanup'])(
671
+ 'blocks native %s while an isolated probe owns the handle',
672
+ async operation => {
673
+ const probe = new WebUsbTransport(true);
674
+ probe.init({ debug: jest.fn() });
675
+ const probeState = probe as unknown as PacketIoTransport;
676
+ jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
677
+ jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
678
+ jest.spyOn(probe, 'connect').mockImplementation(() => {
679
+ device.opened = true;
680
+ return Promise.resolve();
681
+ });
682
+ const native = Object.assign(device, {
683
+ releaseInterface: jest.fn(),
684
+ transferIn: jest
685
+ .fn()
686
+ .mockResolvedValue({ status: 'ok', data: new DataView(new ArrayBuffer(64)) }),
687
+ transferOut: jest.fn().mockResolvedValue({ status: 'ok' }),
688
+ });
689
+ await probe.acquire({ path: 'USB_PATH' });
690
+ const active = new WebUsbTransport();
691
+ active.init({ debug: jest.fn() });
692
+ const state = active as unknown as PacketIoTransport;
693
+ try {
694
+ let call: Promise<unknown>;
695
+ if (operation === 'read') call = state.transferInOnce('USB_PATH', 64);
696
+ else if (operation === 'read retry') call = state.transferInWithRetry('USB_PATH', 64);
697
+ else if (operation === 'write')
698
+ call = state.transferOutOnce('USB_PATH', new Uint8Array([1]));
699
+ else if (operation === 'write retry')
700
+ call = state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
701
+ else
702
+ call = state.reconnectForPacketIoRetry(
703
+ 'USB_PATH',
704
+ 'in',
705
+ 0,
706
+ new Error('transferIn failed')
707
+ );
708
+ await expect(call).rejects.toThrow('USB device is already in use');
709
+ expect(native.transferIn).not.toHaveBeenCalled();
710
+ expect(native.transferOut).not.toHaveBeenCalled();
711
+ expect(native.releaseInterface).not.toHaveBeenCalled();
712
+ expect(device.close).not.toHaveBeenCalled();
713
+ } finally {
714
+ await probe.release('USB_PATH');
715
+ }
716
+ }
717
+ );
718
+
719
+ test.each(['in', 'out'] as const)(
720
+ 'ends transfer%s immediately if reconnect reports an ownership conflict',
721
+ async direction => {
722
+ const active = new WebUsbTransport();
723
+ active.init({ debug: jest.fn() });
724
+ device.opened = true;
725
+ const native = Object.assign(device, {
726
+ transferIn: jest.fn().mockRejectedValue(new Error('transferIn NetworkError')),
727
+ transferOut: jest.fn().mockRejectedValue(new Error('transferOut NetworkError')),
728
+ });
729
+ const state = active as unknown as PacketIoTransport;
730
+ const conflict = ERRORS.TypedError(
731
+ HardwareErrorCode.RuntimeError,
732
+ 'USB device is already in use'
733
+ );
734
+ const reconnect = jest.spyOn(state, 'reconnectForPacketIoRetry').mockRejectedValue(conflict);
735
+ const call =
736
+ direction === 'in'
737
+ ? state.transferInWithRetry('USB_PATH', 64)
738
+ : state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
739
+ await expect(call).rejects.toBe(conflict);
740
+ expect(reconnect).toHaveBeenCalledTimes(1);
741
+ expect(native[direction === 'in' ? 'transferIn' : 'transferOut']).toHaveBeenCalledTimes(1);
742
+ }
743
+ );
744
+
178
745
  test.each([false, true])(
179
746
  'uses a read-only V1 probe only for isolated discovery (%s)',
180
747
  async identityProbe => {
package/dist/index.d.ts CHANGED
@@ -31,6 +31,10 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
31
31
  /** Paths currently acquired (between a successful acquire() and its release()). */
32
32
  private acquiredPaths;
33
33
  private identityProbeDevices;
34
+ private normalDevices;
35
+ private pendingHandleOperations;
36
+ private stopPromise?;
37
+ private identityProbeCleanupPaths;
34
38
  /**
35
39
  * The exact USBDevice object each cached protocol was probed against. The
36
40
  * browser returns the same object identity for a device as long as it stays
@@ -60,6 +64,10 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
60
64
  * Initialize WebUSB transport
61
65
  */
62
66
  init(logger: any, emitter?: EventEmitter): void;
67
+ stop(): Promise<void>;
68
+ private stopResources;
69
+ private assertRunning;
70
+ private runHandleOperation;
63
71
  /**
64
72
  * Announce that a USB device left. Called from the module-scoped disconnect
65
73
  * listener, which is why it is public rather than inlined.
@@ -132,6 +140,9 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
132
140
  */
133
141
  connectToDevice(path: string, first: boolean): Promise<undefined>;
134
142
  private assertHandleAvailable;
143
+ private claimNormalHandle;
144
+ private releaseNormalHandles;
145
+ private isHandleOwnershipError;
135
146
  private claimIdentityProbeHandle;
136
147
  private releaseIdentityProbeHandle;
137
148
  private closeOpenDevice;