@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/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
- await this.closeOpenDevice(input.path);
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
- HardwareErrorCode.RuntimeError,
407
- `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
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
- HardwareErrorCode.DeviceInitializeFailed,
414
- `Protocol ${expected} probe timeout after ${attempts} attempts`
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.DeviceNotFound);
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
- await device.open();
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
- await device.open();
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
- await device.claimInterface(endpoints.interfaceNumber);
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
- const current = this.deviceList.find(device => device.path === path)?.device;
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
- await currentDevice.close();
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
- throw error;
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
- throw lastError;
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
- await device.transferOut(endpointOut, transferBuffer);
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
- throw error;
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
- throw lastError;
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 true;
897
- } catch (_error) {
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
- return this.callProtocolV2Usb(path, name, data, options);
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
- await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1046
- await this.closeOpenDevice(path);
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