@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.
- package/__tests__/webusb-identity-probe.test.ts +570 -3
- package/dist/index.d.ts +11 -0
- package/dist/index.js +215 -18
- package/dist/webusb.d.ts +11 -0
- package/dist/webusb.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/webusb.ts +212 -18
|
@@ -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
|
-
|
|
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
|
-
|
|
109
|
-
expect(
|
|
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;
|