@onekeyfe/hd-transport-web-device 1.2.8-alpha.14 → 1.2.8-alpha.16
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/__tests__/webusb-identity-probe.test.ts +378 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.js +68 -7
- package/dist/webusb.d.ts +5 -0
- package/dist/webusb.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/webusb.ts +74 -7
|
@@ -1,7 +1,37 @@
|
|
|
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';
|
|
2
5
|
|
|
3
6
|
import type { MessageFromOneKey, TransportCallOptions } from '@onekeyfe/hd-transport';
|
|
4
7
|
|
|
8
|
+
jest.mock('@onekeyfe/hd-core', () => jest.requireActual('../../core/src'));
|
|
9
|
+
jest.mock('../../core/src/data/config', () => ({
|
|
10
|
+
getSDKVersion: () => '1.0.0-test',
|
|
11
|
+
DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0-test/',
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('@onekeyfe/hd-transport-web-device', () => ({
|
|
14
|
+
WebUsbTransport: jest.requireActual('../src/webusb').default,
|
|
15
|
+
}));
|
|
16
|
+
jest.mock('@onekeyfe/hd-transport-http', () => ({ __esModule: true, default: jest.fn() }));
|
|
17
|
+
jest.mock('@onekeyfe/hd-transport-lowlevel', () => ({}));
|
|
18
|
+
jest.mock('@onekeyfe/hd-transport-emulator', () => ({}));
|
|
19
|
+
|
|
20
|
+
type PacketIoTransport = {
|
|
21
|
+
rotateProtocolV2UsbGeneration: () => Promise<void>;
|
|
22
|
+
detectProtocol: () => Promise<'V1'>;
|
|
23
|
+
transferInWithRetry: (path: string, length: number) => Promise<DataView>;
|
|
24
|
+
transferInOnce: (path: string, length: number) => Promise<DataView>;
|
|
25
|
+
transferOutWithRetry: (path: string, packet: Uint8Array) => Promise<void>;
|
|
26
|
+
transferOutOnce: (path: string, packet: Uint8Array) => Promise<void>;
|
|
27
|
+
reconnectForPacketIoRetry: (
|
|
28
|
+
path: string,
|
|
29
|
+
direction: 'in' | 'out',
|
|
30
|
+
attempt: number,
|
|
31
|
+
error: unknown
|
|
32
|
+
) => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
|
|
5
35
|
describe('WebUSB identity probe ownership', () => {
|
|
6
36
|
let originalNavigator: PropertyDescriptor | undefined;
|
|
7
37
|
const device = {
|
|
@@ -21,7 +51,10 @@ describe('WebUSB identity probe ownership', () => {
|
|
|
21
51
|
beforeEach(() => {
|
|
22
52
|
originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
|
23
53
|
device.opened = false;
|
|
24
|
-
device.close.
|
|
54
|
+
device.close.mockReset().mockImplementation(() => {
|
|
55
|
+
device.opened = false;
|
|
56
|
+
return Promise.resolve();
|
|
57
|
+
});
|
|
25
58
|
usb.getDevices.mockResolvedValue([device]);
|
|
26
59
|
usb.addEventListener.mockClear();
|
|
27
60
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { usb } });
|
|
@@ -77,6 +110,7 @@ describe('WebUSB identity probe ownership', () => {
|
|
|
77
110
|
expect(device.close).not.toHaveBeenCalled();
|
|
78
111
|
finishOpen();
|
|
79
112
|
await connecting;
|
|
113
|
+
await active.release('USB_PATH');
|
|
80
114
|
});
|
|
81
115
|
|
|
82
116
|
test('a normal connection cannot close an isolated probe handle and release closes the captured handle after re-enumeration', async () => {
|
|
@@ -102,8 +136,10 @@ describe('WebUSB identity probe ownership', () => {
|
|
|
102
136
|
const replacement = { ...device, close: jest.fn() };
|
|
103
137
|
usb.getDevices.mockResolvedValue([replacement]);
|
|
104
138
|
await probe.getConnectedDevices();
|
|
105
|
-
|
|
106
|
-
expect(
|
|
139
|
+
device.close.mockRejectedValueOnce(new Error('native close failed'));
|
|
140
|
+
await expect(probe.release('USB_PATH')).rejects.toThrow('native close failed');
|
|
141
|
+
await probe.enumerate();
|
|
142
|
+
expect(device.close).toHaveBeenCalledTimes(2);
|
|
107
143
|
expect(replacement.close).not.toHaveBeenCalled();
|
|
108
144
|
});
|
|
109
145
|
|
|
@@ -119,6 +155,345 @@ describe('WebUSB identity probe ownership', () => {
|
|
|
119
155
|
expect(probeDisconnect).not.toHaveBeenCalled();
|
|
120
156
|
});
|
|
121
157
|
|
|
158
|
+
test.each(['release', 'acquire failure'])(
|
|
159
|
+
'keeps a probe handle protected after close fails during %s and recovers after cleanup',
|
|
160
|
+
async phase => {
|
|
161
|
+
Object.assign(device, {
|
|
162
|
+
open: jest.fn(() => {
|
|
163
|
+
device.opened = true;
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
}),
|
|
166
|
+
releaseInterface: jest.fn().mockResolvedValue(undefined),
|
|
167
|
+
reset: jest.fn(),
|
|
168
|
+
configurations: [],
|
|
169
|
+
configuration: { configurationValue: 1 },
|
|
170
|
+
selectConfiguration: jest.fn(),
|
|
171
|
+
claimInterface: jest.fn(),
|
|
172
|
+
clearHalt: jest.fn(),
|
|
173
|
+
});
|
|
174
|
+
const probe = new WebUsbTransport(true);
|
|
175
|
+
probe.init({ debug: jest.fn() });
|
|
176
|
+
const internals = probe as unknown as {
|
|
177
|
+
rotateProtocolV2UsbGeneration: () => Promise<void>;
|
|
178
|
+
detectProtocol: () => Promise<'V1'>;
|
|
179
|
+
};
|
|
180
|
+
jest.spyOn(internals, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
181
|
+
jest.spyOn(internals, 'detectProtocol').mockResolvedValue('V1');
|
|
182
|
+
jest.spyOn(probe, 'connect').mockImplementation(() => {
|
|
183
|
+
device.opened = true;
|
|
184
|
+
if (phase === 'acquire failure') return Promise.reject(new Error('probe failed'));
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
});
|
|
187
|
+
const closeFailure = new Error('native close failed');
|
|
188
|
+
device.close.mockRejectedValueOnce(closeFailure);
|
|
189
|
+
if (phase === 'release') {
|
|
190
|
+
await probe.acquire({ path: 'USB_PATH' });
|
|
191
|
+
await expect(probe.release('USB_PATH')).rejects.toBe(closeFailure);
|
|
192
|
+
} else {
|
|
193
|
+
await expect(probe.acquire({ path: 'USB_PATH' })).rejects.toBe(closeFailure);
|
|
194
|
+
}
|
|
195
|
+
expect(device.opened).toBe(true);
|
|
196
|
+
const active = new WebUsbTransport();
|
|
197
|
+
active.init({ debug: jest.fn() });
|
|
198
|
+
await expect(active.connectToDevice('USB_PATH', true)).rejects.toThrow(
|
|
199
|
+
'USB device is already in use'
|
|
200
|
+
);
|
|
201
|
+
expect(device.close).toHaveBeenCalledTimes(1);
|
|
202
|
+
|
|
203
|
+
await probe.release('USB_PATH');
|
|
204
|
+
expect(device.opened).toBe(false);
|
|
205
|
+
await expect(active.connectToDevice('USB_PATH', true)).resolves.toBeUndefined();
|
|
206
|
+
expect(device.opened).toBe(true);
|
|
207
|
+
await active.release('USB_PATH');
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
test.each(['release', 'acquire failure'])(
|
|
212
|
+
'the public probe retries failed %s cleanup on its next call',
|
|
213
|
+
async phase => {
|
|
214
|
+
const internals = WebUsbTransport.prototype as unknown as {
|
|
215
|
+
rotateProtocolV2UsbGeneration: () => Promise<void>;
|
|
216
|
+
detectProtocol: () => Promise<'V1'>;
|
|
217
|
+
};
|
|
218
|
+
jest.spyOn(internals, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
219
|
+
jest.spyOn(internals, 'detectProtocol').mockResolvedValue('V1');
|
|
220
|
+
jest.spyOn(WebUsbTransport.prototype, 'getProtocolType').mockReturnValue('V1');
|
|
221
|
+
jest.spyOn(WebUsbTransport.prototype, 'configure').mockImplementation(() => undefined);
|
|
222
|
+
jest
|
|
223
|
+
.spyOn(WebUsbTransport.prototype, 'configureProtocolV2')
|
|
224
|
+
.mockImplementation(() => undefined);
|
|
225
|
+
const connect = jest.spyOn(WebUsbTransport.prototype, 'connect').mockImplementation(() => {
|
|
226
|
+
device.opened = true;
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
});
|
|
229
|
+
if (phase === 'acquire failure') {
|
|
230
|
+
connect.mockImplementationOnce(() => {
|
|
231
|
+
device.opened = true;
|
|
232
|
+
return Promise.reject(new Error('probe failed'));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
jest.spyOn(WebUsbTransport.prototype, 'call').mockResolvedValue({
|
|
236
|
+
type: 'Features',
|
|
237
|
+
message: { onekey_serial_no: 'PHYSICAL_SERIAL', onekey_device_type: 'CLASSIC1S' },
|
|
238
|
+
});
|
|
239
|
+
device.close.mockRejectedValueOnce(new Error('native close failed'));
|
|
240
|
+
try {
|
|
241
|
+
await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
|
|
242
|
+
expect(device.opened).toBe(true);
|
|
243
|
+
const active = new WebUsbTransport();
|
|
244
|
+
active.init({ debug: jest.fn() });
|
|
245
|
+
await expect(active.connectToDevice('USB_PATH', true)).rejects.toThrow(
|
|
246
|
+
'USB device is already in use'
|
|
247
|
+
);
|
|
248
|
+
device.close.mockRejectedValueOnce(new Error('native close still failing'));
|
|
249
|
+
await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
|
|
250
|
+
expect(device.opened).toBe(true);
|
|
251
|
+
expect(connect).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(device.close).toHaveBeenCalledTimes(2);
|
|
253
|
+
await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toMatchObject([
|
|
254
|
+
{ path: 'USB_PATH', serialNo: 'PHYSICAL_SERIAL' },
|
|
255
|
+
]);
|
|
256
|
+
expect(connect).toHaveBeenCalledTimes(2);
|
|
257
|
+
expect(device.opened).toBe(false);
|
|
258
|
+
} finally {
|
|
259
|
+
jest.restoreAllMocks();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
test.each(['in', 'out'] as const)(
|
|
265
|
+
'the public probe cannot take over a first acquire while transfer%s recovery has closed the handle',
|
|
266
|
+
async direction => {
|
|
267
|
+
const closed = createDeferred<void>();
|
|
268
|
+
const resume = createDeferred<void>();
|
|
269
|
+
Object.assign(device, {
|
|
270
|
+
open: jest.fn(() => {
|
|
271
|
+
device.opened = true;
|
|
272
|
+
return Promise.resolve();
|
|
273
|
+
}),
|
|
274
|
+
releaseInterface: jest.fn(),
|
|
275
|
+
reset: jest.fn(),
|
|
276
|
+
configurations: [],
|
|
277
|
+
configuration: { configurationValue: 1 },
|
|
278
|
+
selectConfiguration: jest.fn(),
|
|
279
|
+
claimInterface: jest.fn(),
|
|
280
|
+
clearHalt: jest.fn(),
|
|
281
|
+
transferIn: jest
|
|
282
|
+
.fn()
|
|
283
|
+
.mockResolvedValue({ status: 'ok', data: new DataView(new ArrayBuffer(64)) }),
|
|
284
|
+
transferOut: jest.fn().mockResolvedValue({ status: 'ok' }),
|
|
285
|
+
});
|
|
286
|
+
const native = device as typeof device & { transferIn: jest.Mock; transferOut: jest.Mock };
|
|
287
|
+
native[direction === 'in' ? 'transferIn' : 'transferOut'].mockRejectedValueOnce(
|
|
288
|
+
new Error(`transfer${direction} NetworkError`)
|
|
289
|
+
);
|
|
290
|
+
device.close.mockImplementationOnce(async () => {
|
|
291
|
+
device.opened = false;
|
|
292
|
+
closed.resolve();
|
|
293
|
+
await resume.promise;
|
|
294
|
+
});
|
|
295
|
+
const active = new WebUsbTransport();
|
|
296
|
+
active.init({ debug: jest.fn() });
|
|
297
|
+
const internals = active as unknown as PacketIoTransport;
|
|
298
|
+
jest
|
|
299
|
+
.spyOn(
|
|
300
|
+
WebUsbTransport.prototype as unknown as PacketIoTransport,
|
|
301
|
+
'rotateProtocolV2UsbGeneration'
|
|
302
|
+
)
|
|
303
|
+
.mockResolvedValue(undefined);
|
|
304
|
+
jest
|
|
305
|
+
.spyOn(WebUsbTransport.prototype as unknown as PacketIoTransport, 'detectProtocol')
|
|
306
|
+
.mockImplementation(async function (this: WebUsbTransport) {
|
|
307
|
+
if (this !== active) return 'V1';
|
|
308
|
+
if (direction === 'in') await internals.transferInWithRetry('USB_PATH', 64);
|
|
309
|
+
else await internals.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
|
|
310
|
+
return 'V1';
|
|
311
|
+
});
|
|
312
|
+
jest.spyOn(WebUsbTransport.prototype, 'getProtocolType').mockReturnValue('V1');
|
|
313
|
+
jest.spyOn(WebUsbTransport.prototype, 'configure').mockImplementation(() => undefined);
|
|
314
|
+
jest
|
|
315
|
+
.spyOn(WebUsbTransport.prototype, 'configureProtocolV2')
|
|
316
|
+
.mockImplementation(() => undefined);
|
|
317
|
+
jest
|
|
318
|
+
.spyOn(WebUsbTransport.prototype, 'connect')
|
|
319
|
+
.mockImplementation(async function (this: WebUsbTransport, path, first) {
|
|
320
|
+
if (this === active && !first) return this.connectToDevice(path, first);
|
|
321
|
+
device.opened = true;
|
|
322
|
+
});
|
|
323
|
+
jest.spyOn(WebUsbTransport.prototype, 'call').mockResolvedValue({
|
|
324
|
+
type: 'Features',
|
|
325
|
+
message: { onekey_serial_no: 'PHYSICAL_SERIAL', onekey_device_type: 'CLASSIC1S' },
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
const acquiring = active.acquire({ path: 'USB_PATH' });
|
|
329
|
+
try {
|
|
330
|
+
await closed.promise;
|
|
331
|
+
await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
|
|
332
|
+
resume.resolve();
|
|
333
|
+
await expect(acquiring).resolves.toBe('USB_PATH');
|
|
334
|
+
} finally {
|
|
335
|
+
resume.resolve();
|
|
336
|
+
await acquiring.catch(() => undefined);
|
|
337
|
+
await active.release('USB_PATH');
|
|
338
|
+
}
|
|
339
|
+
await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toMatchObject([
|
|
340
|
+
{ serialNo: 'PHYSICAL_SERIAL' },
|
|
341
|
+
]);
|
|
342
|
+
} finally {
|
|
343
|
+
jest.restoreAllMocks();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
test('releases ownership after a failed normal acquire', async () => {
|
|
349
|
+
const active = new WebUsbTransport();
|
|
350
|
+
const probe = new WebUsbTransport(true);
|
|
351
|
+
active.init({ debug: jest.fn() });
|
|
352
|
+
probe.init({ debug: jest.fn() });
|
|
353
|
+
const state = active as unknown as PacketIoTransport;
|
|
354
|
+
jest.spyOn(state, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
355
|
+
jest.spyOn(active, 'connect').mockImplementation(() => {
|
|
356
|
+
device.opened = true;
|
|
357
|
+
return Promise.resolve();
|
|
358
|
+
});
|
|
359
|
+
jest.spyOn(state, 'detectProtocol').mockRejectedValue(new Error('protocol failed'));
|
|
360
|
+
await expect(active.acquire({ path: 'USB_PATH' })).rejects.toThrow('protocol failed');
|
|
361
|
+
expect(device.opened).toBe(false);
|
|
362
|
+
const probeState = probe as unknown as PacketIoTransport;
|
|
363
|
+
jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
364
|
+
jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
|
|
365
|
+
jest.spyOn(probe, 'connect').mockImplementation(() => {
|
|
366
|
+
device.opened = true;
|
|
367
|
+
return Promise.resolve();
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
await expect(probe.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
|
|
371
|
+
} finally {
|
|
372
|
+
await probe.release('USB_PATH');
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('releasing one path preserves another closed handle owned by the same transport', async () => {
|
|
377
|
+
const other = {
|
|
378
|
+
...device,
|
|
379
|
+
serialNumber: 'OTHER_PATH',
|
|
380
|
+
opened: false,
|
|
381
|
+
close: jest.fn(() => {
|
|
382
|
+
other.opened = false;
|
|
383
|
+
return Promise.resolve();
|
|
384
|
+
}),
|
|
385
|
+
};
|
|
386
|
+
usb.getDevices.mockResolvedValue([device, other]);
|
|
387
|
+
const active = new WebUsbTransport();
|
|
388
|
+
active.init({ debug: jest.fn() });
|
|
389
|
+
const state = active as unknown as PacketIoTransport;
|
|
390
|
+
jest.spyOn(state, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
391
|
+
jest.spyOn(state, 'detectProtocol').mockResolvedValue('V1');
|
|
392
|
+
jest.spyOn(active, 'connect').mockImplementation(path => {
|
|
393
|
+
const target = path === 'USB_PATH' ? device : other;
|
|
394
|
+
target.opened = true;
|
|
395
|
+
return Promise.resolve();
|
|
396
|
+
});
|
|
397
|
+
await active.acquire({ path: 'USB_PATH' });
|
|
398
|
+
await active.acquire({ path: 'OTHER_PATH' });
|
|
399
|
+
await active.release('USB_PATH');
|
|
400
|
+
other.opened = false;
|
|
401
|
+
const probe = new WebUsbTransport(true);
|
|
402
|
+
probe.init({ debug: jest.fn() });
|
|
403
|
+
const probeState = probe as unknown as PacketIoTransport;
|
|
404
|
+
jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
405
|
+
jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
|
|
406
|
+
jest.spyOn(probe, 'connect').mockImplementation(path => {
|
|
407
|
+
const target = path === 'USB_PATH' ? device : other;
|
|
408
|
+
target.opened = true;
|
|
409
|
+
return Promise.resolve();
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
await expect(probe.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
|
|
413
|
+
await expect(probe.acquire({ path: 'OTHER_PATH' })).rejects.toThrow(
|
|
414
|
+
'USB device is already in use'
|
|
415
|
+
);
|
|
416
|
+
} finally {
|
|
417
|
+
await active.release('OTHER_PATH');
|
|
418
|
+
await probe.release('USB_PATH');
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test.each(['read', 'read retry', 'write', 'write retry', 'retry cleanup'])(
|
|
423
|
+
'blocks native %s while an isolated probe owns the handle',
|
|
424
|
+
async operation => {
|
|
425
|
+
const probe = new WebUsbTransport(true);
|
|
426
|
+
probe.init({ debug: jest.fn() });
|
|
427
|
+
const probeState = probe as unknown as PacketIoTransport;
|
|
428
|
+
jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
|
|
429
|
+
jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
|
|
430
|
+
jest.spyOn(probe, 'connect').mockImplementation(() => {
|
|
431
|
+
device.opened = true;
|
|
432
|
+
return Promise.resolve();
|
|
433
|
+
});
|
|
434
|
+
const native = Object.assign(device, {
|
|
435
|
+
releaseInterface: jest.fn(),
|
|
436
|
+
transferIn: jest
|
|
437
|
+
.fn()
|
|
438
|
+
.mockResolvedValue({ status: 'ok', data: new DataView(new ArrayBuffer(64)) }),
|
|
439
|
+
transferOut: jest.fn().mockResolvedValue({ status: 'ok' }),
|
|
440
|
+
});
|
|
441
|
+
await probe.acquire({ path: 'USB_PATH' });
|
|
442
|
+
const active = new WebUsbTransport();
|
|
443
|
+
active.init({ debug: jest.fn() });
|
|
444
|
+
const state = active as unknown as PacketIoTransport;
|
|
445
|
+
try {
|
|
446
|
+
let call: Promise<unknown>;
|
|
447
|
+
if (operation === 'read') call = state.transferInOnce('USB_PATH', 64);
|
|
448
|
+
else if (operation === 'read retry') call = state.transferInWithRetry('USB_PATH', 64);
|
|
449
|
+
else if (operation === 'write')
|
|
450
|
+
call = state.transferOutOnce('USB_PATH', new Uint8Array([1]));
|
|
451
|
+
else if (operation === 'write retry')
|
|
452
|
+
call = state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
|
|
453
|
+
else
|
|
454
|
+
call = state.reconnectForPacketIoRetry(
|
|
455
|
+
'USB_PATH',
|
|
456
|
+
'in',
|
|
457
|
+
0,
|
|
458
|
+
new Error('transferIn failed')
|
|
459
|
+
);
|
|
460
|
+
await expect(call).rejects.toThrow('USB device is already in use');
|
|
461
|
+
expect(native.transferIn).not.toHaveBeenCalled();
|
|
462
|
+
expect(native.transferOut).not.toHaveBeenCalled();
|
|
463
|
+
expect(native.releaseInterface).not.toHaveBeenCalled();
|
|
464
|
+
expect(device.close).not.toHaveBeenCalled();
|
|
465
|
+
} finally {
|
|
466
|
+
await probe.release('USB_PATH');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
test.each(['in', 'out'] as const)(
|
|
472
|
+
'ends transfer%s immediately if reconnect reports an ownership conflict',
|
|
473
|
+
async direction => {
|
|
474
|
+
const active = new WebUsbTransport();
|
|
475
|
+
active.init({ debug: jest.fn() });
|
|
476
|
+
device.opened = true;
|
|
477
|
+
const native = Object.assign(device, {
|
|
478
|
+
transferIn: jest.fn().mockRejectedValue(new Error('transferIn NetworkError')),
|
|
479
|
+
transferOut: jest.fn().mockRejectedValue(new Error('transferOut NetworkError')),
|
|
480
|
+
});
|
|
481
|
+
const state = active as unknown as PacketIoTransport;
|
|
482
|
+
const conflict = ERRORS.TypedError(
|
|
483
|
+
HardwareErrorCode.RuntimeError,
|
|
484
|
+
'USB device is already in use'
|
|
485
|
+
);
|
|
486
|
+
const reconnect = jest.spyOn(state, 'reconnectForPacketIoRetry').mockRejectedValue(conflict);
|
|
487
|
+
const call =
|
|
488
|
+
direction === 'in'
|
|
489
|
+
? state.transferInWithRetry('USB_PATH', 64)
|
|
490
|
+
: state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
|
|
491
|
+
await expect(call).rejects.toBe(conflict);
|
|
492
|
+
expect(reconnect).toHaveBeenCalledTimes(1);
|
|
493
|
+
expect(native[direction === 'in' ? 'transferIn' : 'transferOut']).toHaveBeenCalledTimes(1);
|
|
494
|
+
}
|
|
495
|
+
);
|
|
496
|
+
|
|
122
497
|
test.each([false, true])(
|
|
123
498
|
'uses a read-only V1 probe only for isolated discovery (%s)',
|
|
124
499
|
async identityProbe => {
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ 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 identityProbeCleanupPaths;
|
|
34
36
|
/**
|
|
35
37
|
* The exact USBDevice object each cached protocol was probed against. The
|
|
36
38
|
* browser returns the same object identity for a device as long as it stays
|
|
@@ -132,6 +134,9 @@ declare class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
|
|
|
132
134
|
*/
|
|
133
135
|
connectToDevice(path: string, first: boolean): Promise<undefined>;
|
|
134
136
|
private assertHandleAvailable;
|
|
137
|
+
private claimNormalHandle;
|
|
138
|
+
private releaseNormalHandles;
|
|
139
|
+
private isHandleOwnershipError;
|
|
135
140
|
private claimIdentityProbeHandle;
|
|
136
141
|
private releaseIdentityProbeHandle;
|
|
137
142
|
private closeOpenDevice;
|
package/dist/index.js
CHANGED
|
@@ -51,6 +51,7 @@ const REPORT_ID = transport.PROTOCOL_V1_REPORT_ID;
|
|
|
51
51
|
const HEADER_LENGTH = transport.PROTOCOL_V1_MESSAGE_HEADER_SIZE;
|
|
52
52
|
const PACKET_IO_MAX_RETRIES = 3;
|
|
53
53
|
const PACKET_IO_RETRY_DELAY = 300;
|
|
54
|
+
const USB_DEVICE_IN_USE = 'USB device is already in use';
|
|
54
55
|
const PROTOCOL_V1_PROBE_TIMEOUT = 5000;
|
|
55
56
|
const PROTOCOL_V2_PROBE_TIMEOUT = 1000;
|
|
56
57
|
const EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS = 2;
|
|
@@ -58,6 +59,7 @@ let activeWebUsbTransport;
|
|
|
58
59
|
let usbDisconnectListenerAttached = false;
|
|
59
60
|
const identityProbeHandles = new WeakSet();
|
|
60
61
|
const openingHandles = new WeakSet();
|
|
62
|
+
const normalHandleOwners = new WeakMap();
|
|
61
63
|
function registerActiveWebUsbTransport(instance, usb) {
|
|
62
64
|
activeWebUsbTransport = instance;
|
|
63
65
|
if (usbDisconnectListenerAttached)
|
|
@@ -87,6 +89,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
87
89
|
this.staleProtocolPaths = new Set();
|
|
88
90
|
this.acquiredPaths = new Set();
|
|
89
91
|
this.identityProbeDevices = new Map();
|
|
92
|
+
this.normalDevices = new Map();
|
|
93
|
+
this.identityProbeCleanupPaths = new Set();
|
|
90
94
|
this.probedDeviceObjects = new Map();
|
|
91
95
|
this.deviceEndpoints = new Map();
|
|
92
96
|
this.name = 'WebUsbTransport';
|
|
@@ -155,6 +159,13 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
155
159
|
}
|
|
156
160
|
enumerate() {
|
|
157
161
|
return __awaiter(this, void 0, void 0, function* () {
|
|
162
|
+
for (const path of this.identityProbeCleanupPaths) {
|
|
163
|
+
try {
|
|
164
|
+
yield this.release(path);
|
|
165
|
+
}
|
|
166
|
+
catch (_a) {
|
|
167
|
+
}
|
|
168
|
+
}
|
|
158
169
|
yield this.getConnectedDevices();
|
|
159
170
|
return this.deviceList;
|
|
160
171
|
});
|
|
@@ -196,6 +207,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
196
207
|
yield this.getConnectedDevices();
|
|
197
208
|
this.claimIdentityProbeHandle(input.path, yield this.findDevice(input.path));
|
|
198
209
|
}
|
|
210
|
+
else {
|
|
211
|
+
this.claimNormalHandle(input.path, yield this.findDevice(input.path));
|
|
212
|
+
}
|
|
199
213
|
try {
|
|
200
214
|
yield this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
|
|
201
215
|
yield this.closeOpenDevice(input.path);
|
|
@@ -262,6 +276,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
262
276
|
}
|
|
263
277
|
finally {
|
|
264
278
|
this.releaseIdentityProbeHandle(input.path);
|
|
279
|
+
this.releaseNormalHandles(input.path);
|
|
265
280
|
}
|
|
266
281
|
throw e;
|
|
267
282
|
}
|
|
@@ -394,6 +409,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
394
409
|
this.releaseIdentityProbeHandle(path);
|
|
395
410
|
this.claimIdentityProbeHandle(path, device);
|
|
396
411
|
}
|
|
412
|
+
if (!this.identityProbe)
|
|
413
|
+
this.claimNormalHandle(path, device);
|
|
397
414
|
this.assertHandleAvailable(device);
|
|
398
415
|
if (!device.opened) {
|
|
399
416
|
openingHandles.add(device);
|
|
@@ -422,6 +439,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
422
439
|
this.releaseIdentityProbeHandle(path);
|
|
423
440
|
this.claimIdentityProbeHandle(path, device);
|
|
424
441
|
}
|
|
442
|
+
if (!this.identityProbe)
|
|
443
|
+
this.claimNormalHandle(path, device);
|
|
425
444
|
this.assertHandleAvailable(device);
|
|
426
445
|
if (!device.opened) {
|
|
427
446
|
openingHandles.add(device);
|
|
@@ -453,23 +472,51 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
453
472
|
});
|
|
454
473
|
}
|
|
455
474
|
assertHandleAvailable(device) {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
475
|
+
const normalOwner = normalHandleOwners.get(device);
|
|
476
|
+
if ((normalOwner && normalOwner !== this) ||
|
|
477
|
+
(identityProbeHandles.has(device) &&
|
|
478
|
+
![...this.identityProbeDevices.values()].includes(device))) {
|
|
479
|
+
throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
claimNormalHandle(path, device) {
|
|
483
|
+
this.assertHandleAvailable(device);
|
|
484
|
+
normalHandleOwners.set(device, this);
|
|
485
|
+
this.normalDevices.set(device, path);
|
|
486
|
+
}
|
|
487
|
+
releaseNormalHandles(path) {
|
|
488
|
+
for (const [device, ownedPath] of this.normalDevices) {
|
|
489
|
+
if (ownedPath === path) {
|
|
490
|
+
normalHandleOwners.delete(device);
|
|
491
|
+
this.normalDevices.delete(device);
|
|
492
|
+
}
|
|
459
493
|
}
|
|
460
494
|
}
|
|
495
|
+
isHandleOwnershipError(error) {
|
|
496
|
+
return (error instanceof hdShared.HardwareError &&
|
|
497
|
+
error.errorCode === hdShared.HardwareErrorCode.RuntimeError &&
|
|
498
|
+
this.getErrorMessage(error).includes(USB_DEVICE_IN_USE));
|
|
499
|
+
}
|
|
461
500
|
claimIdentityProbeHandle(path, device) {
|
|
462
|
-
if (device.opened ||
|
|
463
|
-
|
|
501
|
+
if (device.opened ||
|
|
502
|
+
openingHandles.has(device) ||
|
|
503
|
+
identityProbeHandles.has(device) ||
|
|
504
|
+
normalHandleOwners.has(device)) {
|
|
505
|
+
throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
464
506
|
}
|
|
465
507
|
identityProbeHandles.add(device);
|
|
466
508
|
this.identityProbeDevices.set(path, device);
|
|
467
509
|
}
|
|
468
510
|
releaseIdentityProbeHandle(path) {
|
|
469
511
|
const device = this.identityProbeDevices.get(path);
|
|
512
|
+
if (device === null || device === void 0 ? void 0 : device.opened) {
|
|
513
|
+
this.identityProbeCleanupPaths.add(path);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
470
516
|
if (device)
|
|
471
517
|
identityProbeHandles.delete(device);
|
|
472
518
|
this.identityProbeDevices.delete(path);
|
|
519
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
473
520
|
}
|
|
474
521
|
closeOpenDevice(path) {
|
|
475
522
|
var _a, _b, _c, _d;
|
|
@@ -488,6 +535,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
488
535
|
catch (error) {
|
|
489
536
|
(_c = this.Log) === null || _c === void 0 ? void 0 : _c.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
490
537
|
}
|
|
538
|
+
this.assertHandleAvailable(current);
|
|
491
539
|
try {
|
|
492
540
|
yield current.close();
|
|
493
541
|
}
|
|
@@ -501,6 +549,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
501
549
|
clearEndpointHalt(device, direction, endpointNumber) {
|
|
502
550
|
var _a;
|
|
503
551
|
return __awaiter(this, void 0, void 0, function* () {
|
|
552
|
+
this.assertHandleAvailable(device);
|
|
504
553
|
try {
|
|
505
554
|
yield device.clearHalt(direction, endpointNumber);
|
|
506
555
|
}
|
|
@@ -566,7 +615,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
566
615
|
throwWebUsbIoError(path, operation, error) {
|
|
567
616
|
var _a;
|
|
568
617
|
return __awaiter(this, void 0, void 0, function* () {
|
|
569
|
-
if (this.isWebUsbIoHardwareError(error))
|
|
618
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error))
|
|
570
619
|
throw error;
|
|
571
620
|
if (error instanceof transport.ProtocolV2LinkError && error.code !== 'io')
|
|
572
621
|
throw error;
|
|
@@ -600,7 +649,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
600
649
|
return /LIBUSB_ERROR_ACCESS|\b(?:access|permission) denied\b/i.test(message);
|
|
601
650
|
}
|
|
602
651
|
isRetryablePacketIoError(error) {
|
|
603
|
-
if (this.isWebUsbIoHardwareError(error))
|
|
652
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error))
|
|
604
653
|
return false;
|
|
605
654
|
const message = this.getErrorMessage(error).toLowerCase();
|
|
606
655
|
if (this.isWebUsbPermissionDeniedMessage(message))
|
|
@@ -621,6 +670,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
621
670
|
yield hdShared.wait(attempt * PACKET_IO_RETRY_DELAY);
|
|
622
671
|
try {
|
|
623
672
|
const currentDevice = yield this.findDevice(path);
|
|
673
|
+
this.assertHandleAvailable(currentDevice);
|
|
624
674
|
if (currentDevice.opened) {
|
|
625
675
|
const endpoints = this.deviceEndpoints.get(path);
|
|
626
676
|
const ifaceNum = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.interfaceNumber) !== null && _a !== void 0 ? _a : this.interfaceId;
|
|
@@ -630,10 +680,13 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
630
680
|
catch (releaseError) {
|
|
631
681
|
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
632
682
|
}
|
|
683
|
+
this.assertHandleAvailable(currentDevice);
|
|
633
684
|
yield currentDevice.close();
|
|
634
685
|
}
|
|
635
686
|
}
|
|
636
687
|
catch (closeError) {
|
|
688
|
+
if (this.isHandleOwnershipError(closeError))
|
|
689
|
+
throw closeError;
|
|
637
690
|
this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
|
|
638
691
|
}
|
|
639
692
|
yield this.getConnectedDevices();
|
|
@@ -675,6 +728,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
675
728
|
yield this.reconnectForPacketIoRetry(path, 'out', attempt, error);
|
|
676
729
|
}
|
|
677
730
|
catch (reconnectError) {
|
|
731
|
+
if (this.isHandleOwnershipError(reconnectError))
|
|
732
|
+
throw reconnectError;
|
|
678
733
|
lastError = reconnectError;
|
|
679
734
|
this.Log.debug(`[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(reconnectError)}`);
|
|
680
735
|
}
|
|
@@ -693,6 +748,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
693
748
|
const endpoints = this.deviceEndpoints.get(path);
|
|
694
749
|
const endpointOut = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.endpointOut) !== null && _a !== void 0 ? _a : this.endpointId;
|
|
695
750
|
const transferBuffer = this.toArrayBuffer(packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength));
|
|
751
|
+
this.assertHandleAvailable(device);
|
|
696
752
|
const result = yield device.transferOut(endpointOut, transferBuffer);
|
|
697
753
|
if (result.status !== 'ok') {
|
|
698
754
|
return this.throwWebUsbIoError(path, 'transferOut', new Error(`transferOut status: ${String(result.status)}`));
|
|
@@ -714,6 +770,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
714
770
|
}
|
|
715
771
|
const endpoints = this.deviceEndpoints.get(path);
|
|
716
772
|
const endpointIn = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.endpointIn) !== null && _a !== void 0 ? _a : this.endpointId;
|
|
773
|
+
this.assertHandleAvailable(device);
|
|
717
774
|
const result = yield device.transferIn(endpointIn, length);
|
|
718
775
|
return this.getTransferInData(result);
|
|
719
776
|
}
|
|
@@ -730,6 +787,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
730
787
|
yield this.reconnectForPacketIoRetry(path, 'in', attempt, error);
|
|
731
788
|
}
|
|
732
789
|
catch (reconnectError) {
|
|
790
|
+
if (this.isHandleOwnershipError(reconnectError))
|
|
791
|
+
throw reconnectError;
|
|
733
792
|
lastError = reconnectError;
|
|
734
793
|
this.Log.debug(`[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(reconnectError)}`);
|
|
735
794
|
}
|
|
@@ -747,6 +806,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
747
806
|
}
|
|
748
807
|
const endpoints = this.deviceEndpoints.get(path);
|
|
749
808
|
const endpointIn = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.endpointIn) !== null && _a !== void 0 ? _a : this.endpointId;
|
|
809
|
+
this.assertHandleAvailable(device);
|
|
750
810
|
const result = yield device.transferIn(endpointIn, length);
|
|
751
811
|
return this.getTransferInData(result);
|
|
752
812
|
});
|
|
@@ -933,6 +993,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
|
|
|
933
993
|
}
|
|
934
994
|
finally {
|
|
935
995
|
this.releaseIdentityProbeHandle(path);
|
|
996
|
+
this.releaseNormalHandles(path);
|
|
936
997
|
}
|
|
937
998
|
this.deviceEndpoints.delete(path);
|
|
938
999
|
});
|
package/dist/webusb.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
19
19
|
private staleProtocolPaths;
|
|
20
20
|
private acquiredPaths;
|
|
21
21
|
private identityProbeDevices;
|
|
22
|
+
private normalDevices;
|
|
23
|
+
private identityProbeCleanupPaths;
|
|
22
24
|
private probedDeviceObjects;
|
|
23
25
|
private deviceEndpoints;
|
|
24
26
|
name: string;
|
|
@@ -50,6 +52,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
50
52
|
private discoverEndpoints;
|
|
51
53
|
connectToDevice(path: string, first: boolean): Promise<undefined>;
|
|
52
54
|
private assertHandleAvailable;
|
|
55
|
+
private claimNormalHandle;
|
|
56
|
+
private releaseNormalHandles;
|
|
57
|
+
private isHandleOwnershipError;
|
|
53
58
|
private claimIdentityProbeHandle;
|
|
54
59
|
private releaseIdentityProbeHandle;
|
|
55
60
|
private closeOpenDevice;
|
package/dist/webusb.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
3
|
+
"version": "1.2.8-alpha.16",
|
|
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.
|
|
25
|
-
"@onekeyfe/hd-transport": "1.2.8-alpha.
|
|
24
|
+
"@onekeyfe/hd-shared": "1.2.8-alpha.16",
|
|
25
|
+
"@onekeyfe/hd-transport": "1.2.8-alpha.16"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@onekeyfe/hd-transport-electron": "1.2.8-alpha.
|
|
28
|
+
"@onekeyfe/hd-transport-electron": "1.2.8-alpha.16",
|
|
29
29
|
"@types/w3c-web-usb": "^1.0.6",
|
|
30
30
|
"@types/web-bluetooth": "^0.0.17"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "7ccc3f507e2b536e6b5832e1cbb24a5fc1970ebe"
|
|
33
33
|
}
|
package/src/webusb.ts
CHANGED
|
@@ -44,6 +44,7 @@ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
|
|
|
44
44
|
const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
|
|
45
45
|
const PACKET_IO_MAX_RETRIES = 3;
|
|
46
46
|
const PACKET_IO_RETRY_DELAY = 300;
|
|
47
|
+
const USB_DEVICE_IN_USE = 'USB device is already in use';
|
|
47
48
|
// Legacy devices can take longer to answer Initialize after a WebUSB reset.
|
|
48
49
|
// Keep this aligned with Node USB so a slow Protocol V1 response is not
|
|
49
50
|
// misreported as a protocol mismatch during acquire.
|
|
@@ -86,6 +87,8 @@ let usbDisconnectListenerAttached = false;
|
|
|
86
87
|
// open them, including while native open() is still pending.
|
|
87
88
|
const identityProbeHandles = new WeakSet<USBDevice>();
|
|
88
89
|
const openingHandles = new WeakSet<USBDevice>();
|
|
90
|
+
// A normal acquire keeps ownership even while recovery temporarily closes the handle.
|
|
91
|
+
const normalHandleOwners = new WeakMap<USBDevice, WebUsbTransport>();
|
|
89
92
|
|
|
90
93
|
function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
91
94
|
activeWebUsbTransport = instance;
|
|
@@ -130,6 +133,10 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
130
133
|
|
|
131
134
|
private identityProbeDevices = new Map<string, USBDevice>();
|
|
132
135
|
|
|
136
|
+
private normalDevices = new Map<USBDevice, string>();
|
|
137
|
+
|
|
138
|
+
private identityProbeCleanupPaths = new Set<string>();
|
|
139
|
+
|
|
133
140
|
/**
|
|
134
141
|
* The exact USBDevice object each cached protocol was probed against. The
|
|
135
142
|
* browser returns the same object identity for a device as long as it stays
|
|
@@ -273,6 +280,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
273
280
|
* It does NOT prompt the user to select a device
|
|
274
281
|
*/
|
|
275
282
|
async enumerate() {
|
|
283
|
+
// Retry only our failed cleanup before Core skips already-open USB handles.
|
|
284
|
+
for (const path of this.identityProbeCleanupPaths) {
|
|
285
|
+
try {
|
|
286
|
+
await this.release(path);
|
|
287
|
+
} catch {
|
|
288
|
+
// Keep ownership while native close still fails; retry on the next probe.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
276
291
|
await this.getConnectedDevices();
|
|
277
292
|
return this.deviceList;
|
|
278
293
|
}
|
|
@@ -320,6 +335,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
320
335
|
if (this.identityProbe) {
|
|
321
336
|
await this.getConnectedDevices();
|
|
322
337
|
this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
|
|
338
|
+
} else {
|
|
339
|
+
this.claimNormalHandle(input.path, await this.findDevice(input.path));
|
|
323
340
|
}
|
|
324
341
|
try {
|
|
325
342
|
await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
|
|
@@ -408,6 +425,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
408
425
|
await this.closeOpenDevice(input.path);
|
|
409
426
|
} finally {
|
|
410
427
|
this.releaseIdentityProbeHandle(input.path);
|
|
428
|
+
this.releaseNormalHandles(input.path);
|
|
411
429
|
}
|
|
412
430
|
throw e;
|
|
413
431
|
}
|
|
@@ -588,6 +606,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
588
606
|
this.releaseIdentityProbeHandle(path);
|
|
589
607
|
this.claimIdentityProbeHandle(path, device);
|
|
590
608
|
}
|
|
609
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
591
610
|
this.assertHandleAvailable(device);
|
|
592
611
|
if (!device.opened) {
|
|
593
612
|
openingHandles.add(device);
|
|
@@ -615,6 +634,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
615
634
|
this.releaseIdentityProbeHandle(path);
|
|
616
635
|
this.claimIdentityProbeHandle(path, device);
|
|
617
636
|
}
|
|
637
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
618
638
|
this.assertHandleAvailable(device);
|
|
619
639
|
if (!device.opened) {
|
|
620
640
|
openingHandles.add(device);
|
|
@@ -648,17 +668,47 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
648
668
|
}
|
|
649
669
|
|
|
650
670
|
private assertHandleAvailable(device: USBDevice) {
|
|
671
|
+
const normalOwner = normalHandleOwners.get(device);
|
|
651
672
|
if (
|
|
652
|
-
|
|
653
|
-
|
|
673
|
+
(normalOwner && normalOwner !== this) ||
|
|
674
|
+
(identityProbeHandles.has(device) &&
|
|
675
|
+
![...this.identityProbeDevices.values()].includes(device))
|
|
654
676
|
) {
|
|
655
|
-
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError,
|
|
677
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
656
678
|
}
|
|
657
679
|
}
|
|
658
680
|
|
|
681
|
+
private claimNormalHandle(path: string, device: USBDevice) {
|
|
682
|
+
this.assertHandleAvailable(device);
|
|
683
|
+
normalHandleOwners.set(device, this);
|
|
684
|
+
this.normalDevices.set(device, path);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private releaseNormalHandles(path: string) {
|
|
688
|
+
for (const [device, ownedPath] of this.normalDevices) {
|
|
689
|
+
if (ownedPath === path) {
|
|
690
|
+
normalHandleOwners.delete(device);
|
|
691
|
+
this.normalDevices.delete(device);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private isHandleOwnershipError(error: unknown): error is HardwareError {
|
|
697
|
+
return (
|
|
698
|
+
error instanceof HardwareError &&
|
|
699
|
+
error.errorCode === HardwareErrorCode.RuntimeError &&
|
|
700
|
+
this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
659
704
|
private claimIdentityProbeHandle(path: string, device: USBDevice) {
|
|
660
|
-
if (
|
|
661
|
-
|
|
705
|
+
if (
|
|
706
|
+
device.opened ||
|
|
707
|
+
openingHandles.has(device) ||
|
|
708
|
+
identityProbeHandles.has(device) ||
|
|
709
|
+
normalHandleOwners.has(device)
|
|
710
|
+
) {
|
|
711
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
662
712
|
}
|
|
663
713
|
identityProbeHandles.add(device);
|
|
664
714
|
this.identityProbeDevices.set(path, device);
|
|
@@ -666,8 +716,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
666
716
|
|
|
667
717
|
private releaseIdentityProbeHandle(path: string) {
|
|
668
718
|
const device = this.identityProbeDevices.get(path);
|
|
719
|
+
// A failed native close must not make an open probe handle available to other callers.
|
|
720
|
+
if (device?.opened) {
|
|
721
|
+
this.identityProbeCleanupPaths.add(path);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
669
724
|
if (device) identityProbeHandles.delete(device);
|
|
670
725
|
this.identityProbeDevices.delete(path);
|
|
726
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
671
727
|
}
|
|
672
728
|
|
|
673
729
|
private async closeOpenDevice(path: string) {
|
|
@@ -684,6 +740,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
684
740
|
} catch (error) {
|
|
685
741
|
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
686
742
|
}
|
|
743
|
+
this.assertHandleAvailable(current);
|
|
687
744
|
try {
|
|
688
745
|
await current.close();
|
|
689
746
|
} catch (error) {
|
|
@@ -697,6 +754,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
697
754
|
direction: USBDirection,
|
|
698
755
|
endpointNumber: number
|
|
699
756
|
) {
|
|
757
|
+
this.assertHandleAvailable(device);
|
|
700
758
|
try {
|
|
701
759
|
await device.clearHalt(direction, endpointNumber);
|
|
702
760
|
} catch (error) {
|
|
@@ -772,7 +830,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
772
830
|
operation: WebUsbOperation,
|
|
773
831
|
error: unknown
|
|
774
832
|
): Promise<never> {
|
|
775
|
-
if (this.isWebUsbIoHardwareError(error)) throw error;
|
|
833
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
|
|
776
834
|
if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
|
|
777
835
|
|
|
778
836
|
const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
|
|
@@ -812,7 +870,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
812
870
|
}
|
|
813
871
|
|
|
814
872
|
private isRetryablePacketIoError(error: unknown) {
|
|
815
|
-
if (this.isWebUsbIoHardwareError(error)) return false;
|
|
873
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
|
|
816
874
|
const message = this.getErrorMessage(error).toLowerCase();
|
|
817
875
|
if (this.isWebUsbPermissionDeniedMessage(message)) return false;
|
|
818
876
|
return (
|
|
@@ -846,6 +904,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
846
904
|
|
|
847
905
|
try {
|
|
848
906
|
const currentDevice = await this.findDevice(path);
|
|
907
|
+
this.assertHandleAvailable(currentDevice);
|
|
849
908
|
if (currentDevice.opened) {
|
|
850
909
|
const endpoints = this.deviceEndpoints.get(path);
|
|
851
910
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
@@ -854,9 +913,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
854
913
|
} catch (releaseError) {
|
|
855
914
|
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
856
915
|
}
|
|
916
|
+
this.assertHandleAvailable(currentDevice);
|
|
857
917
|
await currentDevice.close();
|
|
858
918
|
}
|
|
859
919
|
} catch (closeError) {
|
|
920
|
+
if (this.isHandleOwnershipError(closeError)) throw closeError;
|
|
860
921
|
this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
|
|
861
922
|
}
|
|
862
923
|
|
|
@@ -898,6 +959,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
898
959
|
try {
|
|
899
960
|
await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
|
|
900
961
|
} catch (reconnectError) {
|
|
962
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
901
963
|
lastError = reconnectError;
|
|
902
964
|
this.Log.debug(
|
|
903
965
|
`[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -920,6 +982,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
920
982
|
const transferBuffer = this.toArrayBuffer(
|
|
921
983
|
packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
|
|
922
984
|
);
|
|
985
|
+
this.assertHandleAvailable(device);
|
|
923
986
|
const result = await device.transferOut(endpointOut, transferBuffer);
|
|
924
987
|
if (result.status !== 'ok') {
|
|
925
988
|
return this.throwWebUsbIoError(
|
|
@@ -947,6 +1010,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
947
1010
|
}
|
|
948
1011
|
const endpoints = this.deviceEndpoints.get(path);
|
|
949
1012
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1013
|
+
this.assertHandleAvailable(device);
|
|
950
1014
|
const result = await device.transferIn(endpointIn, length);
|
|
951
1015
|
return this.getTransferInData(result);
|
|
952
1016
|
} catch (error) {
|
|
@@ -961,6 +1025,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
961
1025
|
try {
|
|
962
1026
|
await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
|
|
963
1027
|
} catch (reconnectError) {
|
|
1028
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
964
1029
|
lastError = reconnectError;
|
|
965
1030
|
this.Log.debug(
|
|
966
1031
|
`[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -980,6 +1045,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
980
1045
|
}
|
|
981
1046
|
const endpoints = this.deviceEndpoints.get(path);
|
|
982
1047
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1048
|
+
this.assertHandleAvailable(device);
|
|
983
1049
|
const result = await device.transferIn(endpointIn, length);
|
|
984
1050
|
return this.getTransferInData(result);
|
|
985
1051
|
}
|
|
@@ -1213,6 +1279,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1213
1279
|
await this.closeOpenDevice(path);
|
|
1214
1280
|
} finally {
|
|
1215
1281
|
this.releaseIdentityProbeHandle(path);
|
|
1282
|
+
this.releaseNormalHandles(path);
|
|
1216
1283
|
}
|
|
1217
1284
|
// Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
|
|
1218
1285
|
// physical-device property, so the next acquire can skip the wire-level probe.
|