@onekeyfe/hd-transport-web-device 1.2.8-alpha.10 → 1.2.8-alpha.101
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__/electron-ble-transport.test.ts +45 -30
- package/__tests__/webusb-identity-probe.test.ts +887 -0
- package/__tests__/webusb-protocol-cache.test.ts +121 -0
- package/__tests__/webusb-protocol-v2-timeout.test.ts +27 -8
- package/dist/electron-ble-transport.d.ts.map +1 -1
- package/dist/index.d.ts +24 -3
- package/dist/index.js +405 -35
- package/dist/webusb.d.ts +24 -3
- package/dist/webusb.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/electron-ble-transport.ts +10 -15
- package/src/webusb.ts +400 -35
package/src/webusb.ts
CHANGED
|
@@ -13,6 +13,7 @@ import transport, {
|
|
|
13
13
|
} from '@onekeyfe/hd-transport';
|
|
14
14
|
import {
|
|
15
15
|
ERRORS,
|
|
16
|
+
HardwareError,
|
|
16
17
|
HardwareErrorCode,
|
|
17
18
|
ONEKEY_WEBUSB_FILTER,
|
|
18
19
|
inferProtocolHintFromUsbId,
|
|
@@ -43,6 +44,7 @@ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
|
|
|
43
44
|
const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
|
|
44
45
|
const PACKET_IO_MAX_RETRIES = 3;
|
|
45
46
|
const PACKET_IO_RETRY_DELAY = 300;
|
|
47
|
+
const USB_DEVICE_IN_USE = 'USB device is already in use';
|
|
46
48
|
// Legacy devices can take longer to answer Initialize after a WebUSB reset.
|
|
47
49
|
// Keep this aligned with Node USB so a slow Protocol V1 response is not
|
|
48
50
|
// misreported as a protocol mismatch during acquire.
|
|
@@ -70,6 +72,8 @@ interface TransferCancelToken {
|
|
|
70
72
|
cancelled: boolean;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
76
|
+
|
|
73
77
|
/**
|
|
74
78
|
* The navigator.usb disconnect listener is module-scoped and attached at most
|
|
75
79
|
* once: listeners on navigator.usb are global and never garbage collected, so a
|
|
@@ -79,6 +83,12 @@ interface TransferCancelToken {
|
|
|
79
83
|
*/
|
|
80
84
|
let activeWebUsbTransport: WebUsbTransport | undefined;
|
|
81
85
|
let usbDisconnectListenerAttached = false;
|
|
86
|
+
// Only isolated discovery owns these handles. Normal calls must never close or
|
|
87
|
+
// open them, including while native open() is still pending.
|
|
88
|
+
const identityProbeHandles = new WeakSet<USBDevice>();
|
|
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>();
|
|
82
92
|
|
|
83
93
|
function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
84
94
|
activeWebUsbTransport = instance;
|
|
@@ -121,6 +131,16 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
121
131
|
/** Paths currently acquired (between a successful acquire() and its release()). */
|
|
122
132
|
private acquiredPaths: Set<string> = new Set();
|
|
123
133
|
|
|
134
|
+
private identityProbeDevices = new Map<string, USBDevice>();
|
|
135
|
+
|
|
136
|
+
private normalDevices = new Map<USBDevice, string>();
|
|
137
|
+
|
|
138
|
+
private pendingHandleOperations = new Set<Promise<unknown>>();
|
|
139
|
+
|
|
140
|
+
private stopPromise?: Promise<void>;
|
|
141
|
+
|
|
142
|
+
private identityProbeCleanupPaths = new Set<string>();
|
|
143
|
+
|
|
124
144
|
/**
|
|
125
145
|
* The exact USBDevice object each cached protocol was probed against. The
|
|
126
146
|
* browser returns the same object identity for a device as long as it stays
|
|
@@ -157,7 +177,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
157
177
|
|
|
158
178
|
interfaceId = INTERFACE_ID;
|
|
159
179
|
|
|
160
|
-
constructor() {
|
|
180
|
+
constructor(private readonly identityProbe = false) {
|
|
161
181
|
super({
|
|
162
182
|
router: PROTOCOL_V2_CHANNEL_USB,
|
|
163
183
|
maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
|
|
@@ -180,7 +200,97 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
180
200
|
);
|
|
181
201
|
}
|
|
182
202
|
this.usb = usb;
|
|
183
|
-
registerActiveWebUsbTransport(this, usb);
|
|
203
|
+
if (!this.identityProbe) registerActiveWebUsbTransport(this, usb);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
stop(): Promise<void> {
|
|
207
|
+
this.stopped = true;
|
|
208
|
+
this.acquiredPaths.clear();
|
|
209
|
+
if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
|
|
210
|
+
if (!this.stopPromise) {
|
|
211
|
+
this.stopPromise = this.stopResources().catch(error => {
|
|
212
|
+
// A later discovery can retry closure without dropping the handle guard.
|
|
213
|
+
this.stopPromise = undefined;
|
|
214
|
+
throw error;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return this.stopPromise;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async stopResources() {
|
|
221
|
+
let cleanupError: unknown;
|
|
222
|
+
try {
|
|
223
|
+
await this.disposeProtocolV2UsbLinks('WebUSB transport stopped');
|
|
224
|
+
} catch (error) {
|
|
225
|
+
cleanupError = error;
|
|
226
|
+
}
|
|
227
|
+
// Wait for late open/reset/claim/close completions before releasing ownership.
|
|
228
|
+
// Reads are aborted by closing their handle and reject late results below.
|
|
229
|
+
await Promise.allSettled([...this.pendingHandleOperations]);
|
|
230
|
+
const handles = this.identityProbe
|
|
231
|
+
? [...this.identityProbeDevices].map(([path, device]) => [device, path] as const)
|
|
232
|
+
: [...this.normalDevices];
|
|
233
|
+
for (const [device, path] of handles) {
|
|
234
|
+
if (!this.identityProbe && normalHandleOwners.get(device) !== this) {
|
|
235
|
+
this.normalDevices.delete(device);
|
|
236
|
+
} else {
|
|
237
|
+
try {
|
|
238
|
+
if (device.opened) {
|
|
239
|
+
const iface = this.deviceEndpoints.get(path)?.interfaceNumber ?? this.interfaceId;
|
|
240
|
+
try {
|
|
241
|
+
await device.releaseInterface(iface);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
this.Log?.debug('[WebUsbTransport] stop releaseInterface failed:', error);
|
|
244
|
+
}
|
|
245
|
+
await device.close();
|
|
246
|
+
if (device.opened) throw new Error('WebUSB handle remains open after stop');
|
|
247
|
+
}
|
|
248
|
+
if (this.identityProbe) {
|
|
249
|
+
identityProbeHandles.delete(device);
|
|
250
|
+
this.identityProbeDevices.delete(path);
|
|
251
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
252
|
+
} else {
|
|
253
|
+
normalHandleOwners.delete(device);
|
|
254
|
+
this.normalDevices.delete(device);
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
cleanupError ??= error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.deviceList = [];
|
|
262
|
+
this.deviceProtocol.clear();
|
|
263
|
+
this.confirmedDeviceProtocols.clear();
|
|
264
|
+
this.deviceProtocolHints.clear();
|
|
265
|
+
this.staleProtocolPaths.clear();
|
|
266
|
+
this.probedDeviceObjects.clear();
|
|
267
|
+
if (!this.normalDevices.size && !this.identityProbeDevices.size) {
|
|
268
|
+
this.deviceEndpoints.clear();
|
|
269
|
+
}
|
|
270
|
+
if (cleanupError) {
|
|
271
|
+
throw cleanupError instanceof Error
|
|
272
|
+
? cleanupError
|
|
273
|
+
: new Error(this.getErrorMessage(cleanupError));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private assertRunning() {
|
|
278
|
+
if (this.stopped) {
|
|
279
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'WebUSB transport is stopped');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async runHandleOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
284
|
+
this.assertRunning();
|
|
285
|
+
const pending = Promise.resolve(operation());
|
|
286
|
+
this.pendingHandleOperations.add(pending);
|
|
287
|
+
try {
|
|
288
|
+
const result = await pending;
|
|
289
|
+
this.assertRunning();
|
|
290
|
+
return result;
|
|
291
|
+
} finally {
|
|
292
|
+
this.pendingHandleOperations.delete(pending);
|
|
293
|
+
}
|
|
184
294
|
}
|
|
185
295
|
|
|
186
296
|
/**
|
|
@@ -264,6 +374,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
264
374
|
* It does NOT prompt the user to select a device
|
|
265
375
|
*/
|
|
266
376
|
async enumerate() {
|
|
377
|
+
// Retry only our failed cleanup before Core skips already-open USB handles.
|
|
378
|
+
for (const path of this.identityProbeCleanupPaths) {
|
|
379
|
+
try {
|
|
380
|
+
await this.release(path);
|
|
381
|
+
} catch {
|
|
382
|
+
// Keep ownership while native close still fails; retry on the next probe.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
267
385
|
await this.getConnectedDevices();
|
|
268
386
|
return this.deviceList;
|
|
269
387
|
}
|
|
@@ -272,9 +390,21 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
272
390
|
* Get list of connected devices
|
|
273
391
|
*/
|
|
274
392
|
async getConnectedDevices() {
|
|
393
|
+
this.assertRunning();
|
|
275
394
|
if (!this.usb) return [];
|
|
276
395
|
|
|
277
396
|
const devices = await this.usb.getDevices();
|
|
397
|
+
this.assertRunning();
|
|
398
|
+
for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
|
|
399
|
+
if (owner?.stopped) {
|
|
400
|
+
try {
|
|
401
|
+
await owner.stop();
|
|
402
|
+
} catch (error) {
|
|
403
|
+
this.Log?.debug('[WebUsbTransport] stopped owner cleanup failed:', error);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
this.assertRunning();
|
|
278
408
|
const onekeyDevices = devices.filter(dev => {
|
|
279
409
|
const isOneKey = ONEKEY_WEBUSB_FILTER.some(
|
|
280
410
|
(desc: { vendorId: number; productId: number }) =>
|
|
@@ -307,7 +437,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
307
437
|
* Acquire device control
|
|
308
438
|
*/
|
|
309
439
|
async acquire(input: AcquireInput) {
|
|
440
|
+
this.assertRunning();
|
|
310
441
|
if (!input.path) return;
|
|
442
|
+
if (this.identityProbe) {
|
|
443
|
+
await this.getConnectedDevices();
|
|
444
|
+
this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
|
|
445
|
+
} else {
|
|
446
|
+
this.claimNormalHandle(input.path, await this.findDevice(input.path));
|
|
447
|
+
}
|
|
311
448
|
try {
|
|
312
449
|
await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
|
|
313
450
|
await this.closeOpenDevice(input.path);
|
|
@@ -380,18 +517,25 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
380
517
|
input.expectedProtocol,
|
|
381
518
|
protocolHint
|
|
382
519
|
);
|
|
520
|
+
this.assertRunning();
|
|
383
521
|
this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
|
|
384
522
|
const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
|
|
385
523
|
if (probedDevice) {
|
|
386
524
|
this.probedDeviceObjects.set(input.path, probedDevice);
|
|
387
525
|
}
|
|
388
526
|
}
|
|
527
|
+
this.assertRunning();
|
|
389
528
|
this.acquiredPaths.add(input.path);
|
|
390
529
|
return await Promise.resolve(input.path);
|
|
391
530
|
} catch (e) {
|
|
392
531
|
this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
|
393
532
|
this.acquiredPaths.delete(input.path);
|
|
394
|
-
|
|
533
|
+
try {
|
|
534
|
+
await this.closeOpenDevice(input.path);
|
|
535
|
+
} finally {
|
|
536
|
+
this.releaseIdentityProbeHandle(input.path);
|
|
537
|
+
this.releaseNormalHandles(input.path);
|
|
538
|
+
}
|
|
395
539
|
throw e;
|
|
396
540
|
}
|
|
397
541
|
}
|
|
@@ -402,21 +546,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
402
546
|
* fall back to a Protocol V2 Ping probe.
|
|
403
547
|
*/
|
|
404
548
|
private createProtocolMismatchError(expected: ProtocolType) {
|
|
405
|
-
return ERRORS.TypedError(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
);
|
|
549
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
|
|
550
|
+
expectedProtocol: expected,
|
|
551
|
+
});
|
|
409
552
|
}
|
|
410
553
|
|
|
411
554
|
private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
|
|
412
|
-
return ERRORS.TypedError(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
);
|
|
555
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
|
|
556
|
+
expectedProtocol: expected,
|
|
557
|
+
attempts,
|
|
558
|
+
});
|
|
416
559
|
}
|
|
417
560
|
|
|
418
561
|
private createProtocolDetectionError() {
|
|
419
|
-
return ERRORS.TypedError(HardwareErrorCode.
|
|
562
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
|
|
420
563
|
}
|
|
421
564
|
|
|
422
565
|
private async detectProtocol(
|
|
@@ -513,9 +656,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
513
656
|
async connect(path: string, first: boolean) {
|
|
514
657
|
const maxRetries = 5;
|
|
515
658
|
for (let i = 0; i < maxRetries; i++) {
|
|
659
|
+
this.assertRunning();
|
|
516
660
|
try {
|
|
517
661
|
return await this.connectToDevice(path, first);
|
|
518
662
|
} catch (e) {
|
|
663
|
+
this.assertRunning();
|
|
664
|
+
if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.BridgeNeedsPermission) {
|
|
665
|
+
throw e;
|
|
666
|
+
}
|
|
519
667
|
if (i === maxRetries - 1) {
|
|
520
668
|
throw e;
|
|
521
669
|
}
|
|
@@ -562,23 +710,53 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
562
710
|
* Discovers interface/endpoint numbers from USB descriptors on first connection.
|
|
563
711
|
*/
|
|
564
712
|
async connectToDevice(path: string, first: boolean) {
|
|
713
|
+
this.assertRunning();
|
|
565
714
|
let device: USBDevice = await this.findDevice(path);
|
|
715
|
+
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
716
|
+
await this.closeOpenDevice(path);
|
|
717
|
+
this.releaseIdentityProbeHandle(path);
|
|
718
|
+
this.claimIdentityProbeHandle(path, device);
|
|
719
|
+
}
|
|
720
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
721
|
+
this.assertHandleAvailable(device);
|
|
566
722
|
if (!device.opened) {
|
|
567
|
-
|
|
723
|
+
openingHandles.add(device);
|
|
724
|
+
try {
|
|
725
|
+
await this.runHandleOperation(() => device.open());
|
|
726
|
+
} catch (error) {
|
|
727
|
+
return await this.throwWebUsbIoError(path, 'open', error);
|
|
728
|
+
} finally {
|
|
729
|
+
openingHandles.delete(device);
|
|
730
|
+
}
|
|
568
731
|
}
|
|
569
732
|
// A V1 packet retry continues an in-flight exchange. Preserve its endpoint
|
|
570
733
|
// state as in the legacy transport; probing and V2 recovery still reset.
|
|
571
734
|
if (first || this.deviceProtocol.get(path) !== 'V1') {
|
|
572
735
|
try {
|
|
573
|
-
await device.reset();
|
|
736
|
+
await this.runHandleOperation(() => device.reset());
|
|
574
737
|
} catch (error) {
|
|
738
|
+
this.assertRunning();
|
|
575
739
|
this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
|
|
576
740
|
}
|
|
577
741
|
}
|
|
578
742
|
await this.getConnectedDevices();
|
|
579
743
|
device = await this.findDevice(path);
|
|
744
|
+
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
745
|
+
await this.closeOpenDevice(path);
|
|
746
|
+
this.releaseIdentityProbeHandle(path);
|
|
747
|
+
this.claimIdentityProbeHandle(path, device);
|
|
748
|
+
}
|
|
749
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
750
|
+
this.assertHandleAvailable(device);
|
|
580
751
|
if (!device.opened) {
|
|
581
|
-
|
|
752
|
+
openingHandles.add(device);
|
|
753
|
+
try {
|
|
754
|
+
await this.runHandleOperation(() => device.open());
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return await this.throwWebUsbIoError(path, 'open', error);
|
|
757
|
+
} finally {
|
|
758
|
+
openingHandles.delete(device);
|
|
759
|
+
}
|
|
582
760
|
}
|
|
583
761
|
|
|
584
762
|
if (
|
|
@@ -586,32 +764,105 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
586
764
|
!device.configuration ||
|
|
587
765
|
device.configuration.configurationValue !== this.configurationId
|
|
588
766
|
) {
|
|
589
|
-
await device.selectConfiguration(this.configurationId);
|
|
767
|
+
await this.runHandleOperation(() => device.selectConfiguration(this.configurationId));
|
|
590
768
|
}
|
|
591
769
|
|
|
592
770
|
// Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
|
|
593
771
|
const endpoints = this.discoverEndpoints(device);
|
|
594
772
|
this.deviceEndpoints.set(path, endpoints);
|
|
595
|
-
|
|
773
|
+
try {
|
|
774
|
+
await this.runHandleOperation(() => device.claimInterface(endpoints.interfaceNumber));
|
|
775
|
+
} catch (error) {
|
|
776
|
+
return this.throwWebUsbIoError(path, 'claimInterface', error);
|
|
777
|
+
}
|
|
596
778
|
await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
|
|
597
779
|
await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
|
|
598
780
|
}
|
|
599
781
|
|
|
782
|
+
private assertHandleAvailable(device: USBDevice) {
|
|
783
|
+
this.assertRunning();
|
|
784
|
+
const normalOwner = normalHandleOwners.get(device);
|
|
785
|
+
if (
|
|
786
|
+
(normalOwner && normalOwner !== this) ||
|
|
787
|
+
(identityProbeHandles.has(device) &&
|
|
788
|
+
![...this.identityProbeDevices.values()].includes(device))
|
|
789
|
+
) {
|
|
790
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private claimNormalHandle(path: string, device: USBDevice) {
|
|
795
|
+
this.assertHandleAvailable(device);
|
|
796
|
+
normalHandleOwners.set(device, this);
|
|
797
|
+
this.normalDevices.set(device, path);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private releaseNormalHandles(path: string) {
|
|
801
|
+
if (this.stopped) return;
|
|
802
|
+
for (const [device, ownedPath] of this.normalDevices) {
|
|
803
|
+
if (ownedPath === path) {
|
|
804
|
+
normalHandleOwners.delete(device);
|
|
805
|
+
this.normalDevices.delete(device);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private isHandleOwnershipError(error: unknown): error is HardwareError {
|
|
811
|
+
return (
|
|
812
|
+
error instanceof HardwareError &&
|
|
813
|
+
error.errorCode === HardwareErrorCode.RuntimeError &&
|
|
814
|
+
this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private claimIdentityProbeHandle(path: string, device: USBDevice) {
|
|
819
|
+
this.assertRunning();
|
|
820
|
+
if (
|
|
821
|
+
device.opened ||
|
|
822
|
+
openingHandles.has(device) ||
|
|
823
|
+
identityProbeHandles.has(device) ||
|
|
824
|
+
normalHandleOwners.has(device)
|
|
825
|
+
) {
|
|
826
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
827
|
+
}
|
|
828
|
+
identityProbeHandles.add(device);
|
|
829
|
+
this.identityProbeDevices.set(path, device);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private releaseIdentityProbeHandle(path: string) {
|
|
833
|
+
if (this.stopped) return;
|
|
834
|
+
const device = this.identityProbeDevices.get(path);
|
|
835
|
+
// A failed native close must not make an open probe handle available to other callers.
|
|
836
|
+
if (device?.opened) {
|
|
837
|
+
this.identityProbeCleanupPaths.add(path);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (device) identityProbeHandles.delete(device);
|
|
841
|
+
this.identityProbeDevices.delete(path);
|
|
842
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
843
|
+
}
|
|
844
|
+
|
|
600
845
|
private async closeOpenDevice(path: string) {
|
|
601
|
-
|
|
846
|
+
if (this.stopped) return;
|
|
847
|
+
const current = this.identityProbe
|
|
848
|
+
? this.identityProbeDevices.get(path)
|
|
849
|
+
: this.deviceList.find(device => device.path === path)?.device;
|
|
602
850
|
if (!current?.opened) return;
|
|
851
|
+
this.assertHandleAvailable(current);
|
|
603
852
|
|
|
604
853
|
const endpoints = this.deviceEndpoints.get(path);
|
|
605
854
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
606
855
|
try {
|
|
607
|
-
await current.releaseInterface(ifaceNum);
|
|
856
|
+
await this.runHandleOperation(() => current.releaseInterface(ifaceNum));
|
|
608
857
|
} catch (error) {
|
|
609
858
|
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
610
859
|
}
|
|
860
|
+
this.assertHandleAvailable(current);
|
|
611
861
|
try {
|
|
612
|
-
await current.close();
|
|
862
|
+
await this.runHandleOperation(() => current.close());
|
|
613
863
|
} catch (error) {
|
|
614
864
|
this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
|
|
865
|
+
if (this.identityProbe) throw error;
|
|
615
866
|
}
|
|
616
867
|
}
|
|
617
868
|
|
|
@@ -620,9 +871,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
620
871
|
direction: USBDirection,
|
|
621
872
|
endpointNumber: number
|
|
622
873
|
) {
|
|
874
|
+
this.assertHandleAvailable(device);
|
|
623
875
|
try {
|
|
624
|
-
await device.clearHalt(direction, endpointNumber);
|
|
876
|
+
await this.runHandleOperation(() => device.clearHalt(direction, endpointNumber));
|
|
625
877
|
} catch (error) {
|
|
878
|
+
this.assertRunning();
|
|
626
879
|
this.Log?.debug(
|
|
627
880
|
`[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
|
|
628
881
|
error
|
|
@@ -638,6 +891,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
638
891
|
* protocol entry produced the same fail-fast).
|
|
639
892
|
*/
|
|
640
893
|
private assertAcquired(path: string) {
|
|
894
|
+
this.assertRunning();
|
|
641
895
|
if (!this.acquiredPaths.has(path)) {
|
|
642
896
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
|
|
643
897
|
}
|
|
@@ -662,8 +916,84 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
662
916
|
return String(error);
|
|
663
917
|
}
|
|
664
918
|
|
|
919
|
+
private getErrorName(error: unknown) {
|
|
920
|
+
if (typeof error === 'object' && error && 'name' in error) {
|
|
921
|
+
const { name } = error as { name?: unknown };
|
|
922
|
+
return typeof name === 'string' ? name : String(name ?? '');
|
|
923
|
+
}
|
|
924
|
+
return '';
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private isWebUsbIoHardwareError(error: unknown): error is HardwareError {
|
|
928
|
+
return (
|
|
929
|
+
error instanceof HardwareError &&
|
|
930
|
+
(error.errorCode === HardwareErrorCode.BridgeDeviceDisconnected ||
|
|
931
|
+
error.errorCode === HardwareErrorCode.BridgeNeedsPermission ||
|
|
932
|
+
error.errorCode === HardwareErrorCode.WebUsbDeviceAccessError)
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private async isDeviceStillEnumerated(path: string): Promise<boolean | undefined> {
|
|
937
|
+
if (!this.usb) return undefined;
|
|
938
|
+
try {
|
|
939
|
+
const devices = await this.usb.getDevices();
|
|
940
|
+
return devices.some(device => resolveOneKeyUsbDevicePath(device) === path);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
this.Log?.debug('[WebUsbTransport] failed to verify WebUSB device presence:', error);
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private async throwWebUsbIoError(
|
|
948
|
+
path: string,
|
|
949
|
+
operation: WebUsbOperation,
|
|
950
|
+
error: unknown
|
|
951
|
+
): Promise<never> {
|
|
952
|
+
this.assertRunning();
|
|
953
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
|
|
954
|
+
if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
|
|
955
|
+
|
|
956
|
+
const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
|
|
957
|
+
if (this.isWebUsbIoHardwareError(nativeError)) throw nativeError;
|
|
958
|
+
|
|
959
|
+
const nativeErrorName = this.getErrorName(nativeError);
|
|
960
|
+
const nativeErrorMessage = this.getErrorMessage(nativeError);
|
|
961
|
+
// SecurityError alone may indicate a protected interface, not OS permissions.
|
|
962
|
+
if (this.isWebUsbPermissionDeniedMessage(nativeErrorMessage)) {
|
|
963
|
+
throw ERRORS.TypedError(HardwareErrorCode.BridgeNeedsPermission, nativeErrorMessage, {
|
|
964
|
+
operation,
|
|
965
|
+
nativeErrorName: nativeErrorName || undefined,
|
|
966
|
+
nativeErrorMessage,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
const disconnectedByMessage =
|
|
970
|
+
/(?:device )?disconnected|device not found|action was interrupted/i.test(
|
|
971
|
+
`${nativeErrorName}: ${nativeErrorMessage}`
|
|
972
|
+
);
|
|
973
|
+
const stillEnumerated = disconnectedByMessage
|
|
974
|
+
? false
|
|
975
|
+
: await this.isDeviceStillEnumerated(path);
|
|
976
|
+
const errorCode =
|
|
977
|
+
disconnectedByMessage || stillEnumerated === false
|
|
978
|
+
? HardwareErrorCode.BridgeDeviceDisconnected
|
|
979
|
+
: HardwareErrorCode.WebUsbDeviceAccessError;
|
|
980
|
+
|
|
981
|
+
throw ERRORS.TypedError(errorCode, nativeErrorMessage || undefined, {
|
|
982
|
+
operation,
|
|
983
|
+
nativeErrorName: nativeErrorName || undefined,
|
|
984
|
+
nativeErrorMessage: nativeErrorMessage || undefined,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private isWebUsbPermissionDeniedMessage(message: string) {
|
|
989
|
+
return /LIBUSB_ERROR_ACCESS|\b(?:access|permission) denied\b/i.test(message);
|
|
990
|
+
}
|
|
991
|
+
|
|
665
992
|
private isRetryablePacketIoError(error: unknown) {
|
|
993
|
+
if (this.stopped) return false;
|
|
994
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
|
|
666
995
|
const message = this.getErrorMessage(error).toLowerCase();
|
|
996
|
+
if (this.isWebUsbPermissionDeniedMessage(message)) return false;
|
|
667
997
|
return (
|
|
668
998
|
message.includes('transferout') ||
|
|
669
999
|
message.includes('transferin') ||
|
|
@@ -692,20 +1022,25 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
692
1022
|
// USB disconnect event itself was missed.
|
|
693
1023
|
this.staleProtocolPaths.add(path);
|
|
694
1024
|
await wait(attempt * PACKET_IO_RETRY_DELAY);
|
|
1025
|
+
this.assertRunning();
|
|
695
1026
|
|
|
696
1027
|
try {
|
|
697
1028
|
const currentDevice = await this.findDevice(path);
|
|
1029
|
+
this.assertHandleAvailable(currentDevice);
|
|
698
1030
|
if (currentDevice.opened) {
|
|
699
1031
|
const endpoints = this.deviceEndpoints.get(path);
|
|
700
1032
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
701
1033
|
try {
|
|
702
|
-
await currentDevice.releaseInterface(ifaceNum);
|
|
1034
|
+
await this.runHandleOperation(() => currentDevice.releaseInterface(ifaceNum));
|
|
703
1035
|
} catch (releaseError) {
|
|
704
1036
|
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
705
1037
|
}
|
|
706
|
-
|
|
1038
|
+
this.assertHandleAvailable(currentDevice);
|
|
1039
|
+
await this.runHandleOperation(() => currentDevice.close());
|
|
707
1040
|
}
|
|
708
1041
|
} catch (closeError) {
|
|
1042
|
+
this.assertRunning();
|
|
1043
|
+
if (this.isHandleOwnershipError(closeError)) throw closeError;
|
|
709
1044
|
this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
|
|
710
1045
|
}
|
|
711
1046
|
|
|
@@ -742,11 +1077,13 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
742
1077
|
lastError = error;
|
|
743
1078
|
const shouldRetry = attempt < PACKET_IO_MAX_RETRIES && this.isRetryablePacketIoError(error);
|
|
744
1079
|
if (!shouldRetry) {
|
|
745
|
-
|
|
1080
|
+
return this.throwWebUsbIoError(path, 'transferOut', error);
|
|
746
1081
|
}
|
|
747
1082
|
try {
|
|
748
1083
|
await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
|
|
749
1084
|
} catch (reconnectError) {
|
|
1085
|
+
this.assertRunning();
|
|
1086
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
750
1087
|
lastError = reconnectError;
|
|
751
1088
|
this.Log.debug(
|
|
752
1089
|
`[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -756,7 +1093,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
756
1093
|
}
|
|
757
1094
|
}
|
|
758
1095
|
}
|
|
759
|
-
|
|
1096
|
+
return this.throwWebUsbIoError(path, 'transferOut', lastError);
|
|
760
1097
|
}
|
|
761
1098
|
|
|
762
1099
|
private async transferOutOnce(path: string, packet: Uint8Array) {
|
|
@@ -769,7 +1106,16 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
769
1106
|
const transferBuffer = this.toArrayBuffer(
|
|
770
1107
|
packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
|
|
771
1108
|
);
|
|
772
|
-
|
|
1109
|
+
this.assertHandleAvailable(device);
|
|
1110
|
+
const result = await device.transferOut(endpointOut, transferBuffer);
|
|
1111
|
+
this.assertHandleAvailable(device);
|
|
1112
|
+
if (result.status !== 'ok') {
|
|
1113
|
+
return this.throwWebUsbIoError(
|
|
1114
|
+
path,
|
|
1115
|
+
'transferOut',
|
|
1116
|
+
new Error(`transferOut status: ${String(result.status)}`)
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
773
1119
|
}
|
|
774
1120
|
|
|
775
1121
|
private async transferInWithRetry(
|
|
@@ -789,7 +1135,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
789
1135
|
}
|
|
790
1136
|
const endpoints = this.deviceEndpoints.get(path);
|
|
791
1137
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1138
|
+
this.assertHandleAvailable(device);
|
|
792
1139
|
const result = await device.transferIn(endpointIn, length);
|
|
1140
|
+
this.assertHandleAvailable(device);
|
|
793
1141
|
return this.getTransferInData(result);
|
|
794
1142
|
} catch (error) {
|
|
795
1143
|
lastError = error;
|
|
@@ -798,11 +1146,13 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
798
1146
|
}
|
|
799
1147
|
const shouldRetry = attempt < PACKET_IO_MAX_RETRIES && this.isRetryablePacketIoError(error);
|
|
800
1148
|
if (!shouldRetry) {
|
|
801
|
-
|
|
1149
|
+
return this.throwWebUsbIoError(path, 'transferIn', error);
|
|
802
1150
|
}
|
|
803
1151
|
try {
|
|
804
1152
|
await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
|
|
805
1153
|
} catch (reconnectError) {
|
|
1154
|
+
this.assertRunning();
|
|
1155
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
806
1156
|
lastError = reconnectError;
|
|
807
1157
|
this.Log.debug(
|
|
808
1158
|
`[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -812,7 +1162,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
812
1162
|
}
|
|
813
1163
|
}
|
|
814
1164
|
}
|
|
815
|
-
|
|
1165
|
+
return this.throwWebUsbIoError(path, 'transferIn', lastError);
|
|
816
1166
|
}
|
|
817
1167
|
|
|
818
1168
|
private async transferInOnce(path: string, length: number): Promise<DataView> {
|
|
@@ -822,7 +1172,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
822
1172
|
}
|
|
823
1173
|
const endpoints = this.deviceEndpoints.get(path);
|
|
824
1174
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1175
|
+
this.assertHandleAvailable(device);
|
|
825
1176
|
const result = await device.transferIn(endpointIn, length);
|
|
1177
|
+
this.assertHandleAvailable(device);
|
|
826
1178
|
return this.getTransferInData(result);
|
|
827
1179
|
}
|
|
828
1180
|
|
|
@@ -885,16 +1237,17 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
885
1237
|
}
|
|
886
1238
|
|
|
887
1239
|
try {
|
|
888
|
-
await this.callProtocolV1(
|
|
1240
|
+
const response = await this.callProtocolV1(
|
|
889
1241
|
path,
|
|
890
|
-
'Initialize',
|
|
1242
|
+
this.identityProbe ? 'GetFeatures' : 'Initialize',
|
|
891
1243
|
{},
|
|
892
1244
|
{
|
|
893
1245
|
timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
|
|
894
1246
|
}
|
|
895
1247
|
);
|
|
896
|
-
return
|
|
897
|
-
} catch (
|
|
1248
|
+
return !this.identityProbe || response.type === 'Features';
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
if (this.isWebUsbIoHardwareError(error)) throw error;
|
|
898
1251
|
return false;
|
|
899
1252
|
}
|
|
900
1253
|
}
|
|
@@ -909,6 +1262,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
909
1262
|
timeoutMs: PROTOCOL_V2_PROBE_TIMEOUT,
|
|
910
1263
|
logger: this.Log,
|
|
911
1264
|
logPrefix: 'ProtocolV2 WebUSB',
|
|
1265
|
+
shouldRethrow: error => this.isWebUsbIoHardwareError(error),
|
|
912
1266
|
});
|
|
913
1267
|
}
|
|
914
1268
|
|
|
@@ -986,7 +1340,13 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
986
1340
|
data: Record<string, unknown>,
|
|
987
1341
|
options?: TransportCallOptions
|
|
988
1342
|
) {
|
|
989
|
-
|
|
1343
|
+
try {
|
|
1344
|
+
return await this.callProtocolV2Usb(path, name, data, options);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
if (!(error instanceof ProtocolV2LinkError) || error.code !== 'io') throw error;
|
|
1347
|
+
const operation = error.message.includes(' USB write failed:') ? 'transferOut' : 'transferIn';
|
|
1348
|
+
return this.throwWebUsbIoError(path, operation, error);
|
|
1349
|
+
}
|
|
990
1350
|
}
|
|
991
1351
|
|
|
992
1352
|
/**
|
|
@@ -1042,8 +1402,13 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1042
1402
|
*/
|
|
1043
1403
|
async release(path: string) {
|
|
1044
1404
|
this.acquiredPaths.delete(path);
|
|
1045
|
-
|
|
1046
|
-
|
|
1405
|
+
try {
|
|
1406
|
+
await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
|
|
1407
|
+
await this.closeOpenDevice(path);
|
|
1408
|
+
} finally {
|
|
1409
|
+
this.releaseIdentityProbeHandle(path);
|
|
1410
|
+
this.releaseNormalHandles(path);
|
|
1411
|
+
}
|
|
1047
1412
|
// Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
|
|
1048
1413
|
// physical-device property, so the next acquire can skip the wire-level probe.
|
|
1049
1414
|
// V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link
|