@onekeyfe/hd-transport-web-device 1.2.8-alpha.101 → 1.2.8-alpha.103
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 +47 -0
- package/__tests__/webusb-protocol-cache.test.ts +219 -1
- package/dist/index.d.ts +20 -2
- package/dist/index.js +373 -65
- package/dist/webusb.d.ts +21 -2
- package/dist/webusb.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/webusb.ts +494 -57
package/src/webusb.ts
CHANGED
|
@@ -45,6 +45,8 @@ const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
|
|
|
45
45
|
const PACKET_IO_MAX_RETRIES = 3;
|
|
46
46
|
const PACKET_IO_RETRY_DELAY = 300;
|
|
47
47
|
const USB_DEVICE_IN_USE = 'USB device is already in use';
|
|
48
|
+
const WEBUSB_LIFECYCLE_LOG_PREFIX = '[WebUsbTransport][lifecycle]';
|
|
49
|
+
const WEBUSB_ERROR_MESSAGE_LIMIT = 240;
|
|
48
50
|
// Legacy devices can take longer to answer Initialize after a WebUSB reset.
|
|
49
51
|
// Keep this aligned with Node USB so a slow Protocol V1 response is not
|
|
50
52
|
// misreported as a protocol mismatch during acquire.
|
|
@@ -74,6 +76,23 @@ interface TransferCancelToken {
|
|
|
74
76
|
|
|
75
77
|
type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
76
78
|
|
|
79
|
+
type WebUsbHandleOperation =
|
|
80
|
+
| 'open'
|
|
81
|
+
| 'reset'
|
|
82
|
+
| 'selectConfiguration'
|
|
83
|
+
| 'claimInterface'
|
|
84
|
+
| 'clearHaltIn'
|
|
85
|
+
| 'clearHaltOut'
|
|
86
|
+
| 'releaseInterface'
|
|
87
|
+
| 'close';
|
|
88
|
+
|
|
89
|
+
type WebUsbLifecyclePhase = 'start' | 'success' | 'error' | 'skipped';
|
|
90
|
+
|
|
91
|
+
interface WebUsbConnectOptions {
|
|
92
|
+
expectedProtocol?: ProtocolType;
|
|
93
|
+
forceProtocolDetection?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
/**
|
|
78
97
|
* The navigator.usb disconnect listener is module-scoped and attached at most
|
|
79
98
|
* once: listeners on navigator.usb are global and never garbage collected, so a
|
|
@@ -83,6 +102,7 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
|
83
102
|
*/
|
|
84
103
|
let activeWebUsbTransport: WebUsbTransport | undefined;
|
|
85
104
|
let usbDisconnectListenerAttached = false;
|
|
105
|
+
let nextWebUsbTransportInstanceId = 0;
|
|
86
106
|
// Only isolated discovery owns these handles. Normal calls must never close or
|
|
87
107
|
// open them, including while native open() is still pending.
|
|
88
108
|
const identityProbeHandles = new WeakSet<USBDevice>();
|
|
@@ -107,6 +127,14 @@ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
|
|
130
|
+
private readonly transportInstanceId = ++nextWebUsbTransportInstanceId;
|
|
131
|
+
|
|
132
|
+
private handleOperationSequence = 0;
|
|
133
|
+
|
|
134
|
+
private deviceLogSequence = 0;
|
|
135
|
+
|
|
136
|
+
private deviceLogIds = new WeakMap<USBDevice, number>();
|
|
137
|
+
|
|
110
138
|
messages: ReturnType<typeof transport.parseConfigure> | undefined;
|
|
111
139
|
|
|
112
140
|
/** Protobuf schema for Protocol V2 transports. */
|
|
@@ -204,15 +232,30 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
204
232
|
}
|
|
205
233
|
|
|
206
234
|
stop(): Promise<void> {
|
|
235
|
+
const startedAt = Date.now();
|
|
207
236
|
this.stopped = true;
|
|
208
237
|
this.acquiredPaths.clear();
|
|
209
238
|
if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
|
|
210
239
|
if (!this.stopPromise) {
|
|
211
|
-
this.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
240
|
+
this.logLifecycle('stop', { phase: 'start' });
|
|
241
|
+
this.stopPromise = this.stopResources().then(
|
|
242
|
+
() => {
|
|
243
|
+
this.logLifecycle('stop', {
|
|
244
|
+
phase: 'success',
|
|
245
|
+
durationMs: Date.now() - startedAt,
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
error => {
|
|
249
|
+
this.logLifecycle('stop', {
|
|
250
|
+
phase: 'error',
|
|
251
|
+
durationMs: Date.now() - startedAt,
|
|
252
|
+
...this.getDiagnosticError(error),
|
|
253
|
+
});
|
|
254
|
+
// A later discovery can retry closure without dropping the handle guard.
|
|
255
|
+
this.stopPromise = undefined;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
);
|
|
216
259
|
}
|
|
217
260
|
return this.stopPromise;
|
|
218
261
|
}
|
|
@@ -237,12 +280,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
237
280
|
try {
|
|
238
281
|
if (device.opened) {
|
|
239
282
|
const iface = this.deviceEndpoints.get(path)?.interfaceNumber ?? this.interfaceId;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
283
|
+
if (this.getInterfaceClaimed(device, iface) === false) {
|
|
284
|
+
this.logSkippedHandleOperation(
|
|
285
|
+
'releaseInterface',
|
|
286
|
+
device,
|
|
287
|
+
'interface-not-claimed',
|
|
288
|
+
iface
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
try {
|
|
292
|
+
await this.runLoggedHandleOperation({
|
|
293
|
+
operation: 'releaseInterface',
|
|
294
|
+
device,
|
|
295
|
+
path,
|
|
296
|
+
interfaceNumber: iface,
|
|
297
|
+
allowStopped: true,
|
|
298
|
+
task: () => device.releaseInterface(iface),
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
this.Log?.debug('[WebUsbTransport] stop releaseInterface failed:', error);
|
|
302
|
+
}
|
|
244
303
|
}
|
|
245
|
-
await
|
|
304
|
+
await this.runLoggedHandleOperation({
|
|
305
|
+
operation: 'close',
|
|
306
|
+
device,
|
|
307
|
+
path,
|
|
308
|
+
allowStopped: true,
|
|
309
|
+
task: () => device.close(),
|
|
310
|
+
});
|
|
246
311
|
if (device.opened) throw new Error('WebUSB handle remains open after stop');
|
|
247
312
|
}
|
|
248
313
|
if (this.identityProbe) {
|
|
@@ -293,6 +358,122 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
293
358
|
}
|
|
294
359
|
}
|
|
295
360
|
|
|
361
|
+
private getInterfaceClaimed(device: USBDevice, interfaceNumber: number) {
|
|
362
|
+
return device.configuration?.interfaces?.find(
|
|
363
|
+
usbInterface => usbInterface.interfaceNumber === interfaceNumber
|
|
364
|
+
)?.claimed;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private getDeviceLogId(device: USBDevice) {
|
|
368
|
+
const existing = this.deviceLogIds.get(device);
|
|
369
|
+
if (existing) return existing;
|
|
370
|
+
const next = ++this.deviceLogSequence;
|
|
371
|
+
this.deviceLogIds.set(device, next);
|
|
372
|
+
return next;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private getHandleLogState(device: USBDevice, interfaceNumber?: number) {
|
|
376
|
+
const interfaceClaimed =
|
|
377
|
+
interfaceNumber === undefined ? undefined : this.getInterfaceClaimed(device, interfaceNumber);
|
|
378
|
+
return {
|
|
379
|
+
deviceRefId: this.getDeviceLogId(device),
|
|
380
|
+
opened: device.opened,
|
|
381
|
+
configurationValue: device.configuration?.configurationValue ?? null,
|
|
382
|
+
interfaceNumber: interfaceNumber ?? null,
|
|
383
|
+
interfaceClaimed: interfaceClaimed ?? null,
|
|
384
|
+
identityProbe: this.identityProbe,
|
|
385
|
+
stopped: this.stopped,
|
|
386
|
+
acquiredPathCount: this.acquiredPaths.size,
|
|
387
|
+
normalHandleCount: this.normalDevices.size,
|
|
388
|
+
identityProbeHandleCount: this.identityProbeDevices.size,
|
|
389
|
+
pendingHandleOperationCount: this.pendingHandleOperations.size,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private getDiagnosticError(error: unknown, path?: string) {
|
|
394
|
+
const errorMessage = this.getErrorMessage(error);
|
|
395
|
+
const redactedMessage = path ? errorMessage.split(path).join('<device>') : errorMessage;
|
|
396
|
+
return {
|
|
397
|
+
errorName: this.getErrorName(error) || 'Error',
|
|
398
|
+
errorMessage: redactedMessage.slice(0, WEBUSB_ERROR_MESSAGE_LIMIT),
|
|
399
|
+
errorCode: error instanceof HardwareError ? error.errorCode : undefined,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private logLifecycle(event: string, payload: Record<string, unknown> = {}) {
|
|
404
|
+
this.Log?.debug(WEBUSB_LIFECYCLE_LOG_PREFIX, {
|
|
405
|
+
event,
|
|
406
|
+
transportInstanceId: this.transportInstanceId,
|
|
407
|
+
...payload,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private logSkippedHandleOperation(
|
|
412
|
+
operation: WebUsbHandleOperation,
|
|
413
|
+
device: USBDevice,
|
|
414
|
+
reason: string,
|
|
415
|
+
interfaceNumber?: number
|
|
416
|
+
) {
|
|
417
|
+
const operationId = ++this.handleOperationSequence;
|
|
418
|
+
this.logLifecycle('handle-operation', {
|
|
419
|
+
operationId,
|
|
420
|
+
operation,
|
|
421
|
+
phase: 'skipped' satisfies WebUsbLifecyclePhase,
|
|
422
|
+
reason,
|
|
423
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private async runLoggedHandleOperation<T>({
|
|
428
|
+
operation,
|
|
429
|
+
device,
|
|
430
|
+
task,
|
|
431
|
+
path,
|
|
432
|
+
interfaceNumber,
|
|
433
|
+
endpointNumber,
|
|
434
|
+
allowStopped = false,
|
|
435
|
+
}: {
|
|
436
|
+
operation: WebUsbHandleOperation;
|
|
437
|
+
device: USBDevice;
|
|
438
|
+
task: () => Promise<T>;
|
|
439
|
+
path?: string;
|
|
440
|
+
interfaceNumber?: number;
|
|
441
|
+
endpointNumber?: number;
|
|
442
|
+
allowStopped?: boolean;
|
|
443
|
+
}): Promise<T> {
|
|
444
|
+
const operationId = ++this.handleOperationSequence;
|
|
445
|
+
const startedAt = Date.now();
|
|
446
|
+
const details = {
|
|
447
|
+
operationId,
|
|
448
|
+
operation,
|
|
449
|
+
...(endpointNumber === undefined ? {} : { endpointNumber }),
|
|
450
|
+
};
|
|
451
|
+
this.logLifecycle('handle-operation', {
|
|
452
|
+
...details,
|
|
453
|
+
phase: 'start' satisfies WebUsbLifecyclePhase,
|
|
454
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
const result = allowStopped ? await task() : await this.runHandleOperation(task);
|
|
458
|
+
this.logLifecycle('handle-operation', {
|
|
459
|
+
...details,
|
|
460
|
+
phase: 'success' satisfies WebUsbLifecyclePhase,
|
|
461
|
+
durationMs: Date.now() - startedAt,
|
|
462
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
463
|
+
});
|
|
464
|
+
return result;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
this.logLifecycle('handle-operation', {
|
|
467
|
+
...details,
|
|
468
|
+
phase: 'error' satisfies WebUsbLifecyclePhase,
|
|
469
|
+
durationMs: Date.now() - startedAt,
|
|
470
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
471
|
+
...this.getDiagnosticError(error, path),
|
|
472
|
+
});
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
296
477
|
/**
|
|
297
478
|
* Announce that a USB device left. Called from the module-scoped disconnect
|
|
298
479
|
* listener, which is why it is public rather than inlined.
|
|
@@ -356,10 +537,22 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
356
537
|
*/
|
|
357
538
|
async promptDeviceAccess() {
|
|
358
539
|
if (!this.usb) return null;
|
|
540
|
+
const startedAt = Date.now();
|
|
541
|
+
this.logLifecycle('prompt-device-access', { phase: 'start' });
|
|
359
542
|
try {
|
|
360
543
|
const device = await this.usb.requestDevice({ filters: ONEKEY_WEBUSB_FILTER });
|
|
544
|
+
this.logLifecycle('prompt-device-access', {
|
|
545
|
+
phase: 'success',
|
|
546
|
+
durationMs: Date.now() - startedAt,
|
|
547
|
+
selected: Boolean(device),
|
|
548
|
+
});
|
|
361
549
|
return device;
|
|
362
550
|
} catch (e) {
|
|
551
|
+
this.logLifecycle('prompt-device-access', {
|
|
552
|
+
phase: 'error',
|
|
553
|
+
durationMs: Date.now() - startedAt,
|
|
554
|
+
...this.getDiagnosticError(e),
|
|
555
|
+
});
|
|
363
556
|
this.Log.debug(
|
|
364
557
|
'requestDevice error: ',
|
|
365
558
|
e instanceof Error ? `${e.name}: ${e.message}` : String(e)
|
|
@@ -374,16 +567,35 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
374
567
|
* It does NOT prompt the user to select a device
|
|
375
568
|
*/
|
|
376
569
|
async enumerate() {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
570
|
+
const startedAt = Date.now();
|
|
571
|
+
this.logLifecycle('enumerate', {
|
|
572
|
+
phase: 'start',
|
|
573
|
+
pendingProbeCleanupCount: this.identityProbeCleanupPaths.size,
|
|
574
|
+
});
|
|
575
|
+
try {
|
|
576
|
+
// Retry only our failed cleanup before Core skips already-open USB handles.
|
|
577
|
+
for (const path of this.identityProbeCleanupPaths) {
|
|
578
|
+
try {
|
|
579
|
+
await this.release(path);
|
|
580
|
+
} catch {
|
|
581
|
+
// Keep ownership while native close still fails; retry on the next probe.
|
|
582
|
+
}
|
|
383
583
|
}
|
|
584
|
+
await this.getConnectedDevices();
|
|
585
|
+
this.logLifecycle('enumerate', {
|
|
586
|
+
phase: 'success',
|
|
587
|
+
durationMs: Date.now() - startedAt,
|
|
588
|
+
deviceCount: this.deviceList.length,
|
|
589
|
+
});
|
|
590
|
+
return this.deviceList;
|
|
591
|
+
} catch (error) {
|
|
592
|
+
this.logLifecycle('enumerate', {
|
|
593
|
+
phase: 'error',
|
|
594
|
+
durationMs: Date.now() - startedAt,
|
|
595
|
+
...this.getDiagnosticError(error),
|
|
596
|
+
});
|
|
597
|
+
throw error;
|
|
384
598
|
}
|
|
385
|
-
await this.getConnectedDevices();
|
|
386
|
-
return this.deviceList;
|
|
387
599
|
}
|
|
388
600
|
|
|
389
601
|
/**
|
|
@@ -393,7 +605,24 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
393
605
|
this.assertRunning();
|
|
394
606
|
if (!this.usb) return [];
|
|
395
607
|
|
|
396
|
-
const
|
|
608
|
+
const startedAt = Date.now();
|
|
609
|
+
this.logLifecycle('get-devices', { phase: 'start' });
|
|
610
|
+
let devices: USBDevice[];
|
|
611
|
+
try {
|
|
612
|
+
devices = await this.usb.getDevices();
|
|
613
|
+
this.logLifecycle('get-devices', {
|
|
614
|
+
phase: 'success',
|
|
615
|
+
durationMs: Date.now() - startedAt,
|
|
616
|
+
authorizedDeviceCount: devices.length,
|
|
617
|
+
});
|
|
618
|
+
} catch (error) {
|
|
619
|
+
this.logLifecycle('get-devices', {
|
|
620
|
+
phase: 'error',
|
|
621
|
+
durationMs: Date.now() - startedAt,
|
|
622
|
+
...this.getDiagnosticError(error),
|
|
623
|
+
});
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
397
626
|
this.assertRunning();
|
|
398
627
|
for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
|
|
399
628
|
if (owner?.stopped) {
|
|
@@ -439,16 +668,28 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
439
668
|
async acquire(input: AcquireInput) {
|
|
440
669
|
this.assertRunning();
|
|
441
670
|
if (!input.path) return;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
671
|
+
const startedAt = Date.now();
|
|
672
|
+
this.logLifecycle('acquire', {
|
|
673
|
+
phase: 'start',
|
|
674
|
+
expectedProtocol: input.expectedProtocol ?? null,
|
|
675
|
+
protocolHint: input.protocolHint ?? null,
|
|
676
|
+
forceProtocolDetection: Boolean(input.forceProtocolDetection),
|
|
677
|
+
skipProtocolProbe: Boolean(input.skipProtocolProbe),
|
|
678
|
+
hasCachedProtocol: this.deviceProtocol.has(input.path),
|
|
679
|
+
});
|
|
448
680
|
try {
|
|
681
|
+
if (this.identityProbe) {
|
|
682
|
+
await this.getConnectedDevices();
|
|
683
|
+
this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
|
|
684
|
+
} else {
|
|
685
|
+
this.claimNormalHandle(input.path, await this.findDevice(input.path));
|
|
686
|
+
}
|
|
449
687
|
await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
|
|
450
688
|
await this.closeOpenDevice(input.path);
|
|
451
|
-
await this.connect(input.path ?? '', true
|
|
689
|
+
await this.connect(input.path ?? '', true, {
|
|
690
|
+
expectedProtocol: input.expectedProtocol,
|
|
691
|
+
forceProtocolDetection: input.forceProtocolDetection,
|
|
692
|
+
});
|
|
452
693
|
if (input.skipProtocolProbe) {
|
|
453
694
|
if (!input.expectedProtocol) {
|
|
454
695
|
throw ERRORS.TypedError(
|
|
@@ -526,8 +767,18 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
526
767
|
}
|
|
527
768
|
this.assertRunning();
|
|
528
769
|
this.acquiredPaths.add(input.path);
|
|
770
|
+
this.logLifecycle('acquire', {
|
|
771
|
+
phase: 'success',
|
|
772
|
+
durationMs: Date.now() - startedAt,
|
|
773
|
+
protocol: this.deviceProtocol.get(input.path) ?? null,
|
|
774
|
+
});
|
|
529
775
|
return await Promise.resolve(input.path);
|
|
530
776
|
} catch (e) {
|
|
777
|
+
this.logLifecycle('acquire', {
|
|
778
|
+
phase: 'error',
|
|
779
|
+
durationMs: Date.now() - startedAt,
|
|
780
|
+
...this.getDiagnosticError(e, input.path),
|
|
781
|
+
});
|
|
531
782
|
this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
|
532
783
|
this.acquiredPaths.delete(input.path);
|
|
533
784
|
try {
|
|
@@ -653,18 +904,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
653
904
|
/**
|
|
654
905
|
* Connect to device with retry mechanism
|
|
655
906
|
*/
|
|
656
|
-
async connect(path: string, first: boolean) {
|
|
907
|
+
async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
657
908
|
const maxRetries = 5;
|
|
658
909
|
for (let i = 0; i < maxRetries; i++) {
|
|
659
910
|
this.assertRunning();
|
|
911
|
+
const attempt = i + 1;
|
|
912
|
+
const startedAt = Date.now();
|
|
913
|
+
const connectionStrategy = this.shouldUseLegacyV1Connection(path, options)
|
|
914
|
+
? 'protocol-v1-legacy-1.1.31'
|
|
915
|
+
: 'current';
|
|
916
|
+
this.logLifecycle('connect-attempt', {
|
|
917
|
+
phase: 'start',
|
|
918
|
+
attempt,
|
|
919
|
+
maxAttempts: maxRetries,
|
|
920
|
+
first,
|
|
921
|
+
connectionStrategy,
|
|
922
|
+
});
|
|
660
923
|
try {
|
|
661
|
-
|
|
924
|
+
const result = await this.connectToDevice(path, first, options);
|
|
925
|
+
this.logLifecycle('connect-attempt', {
|
|
926
|
+
phase: 'success',
|
|
927
|
+
attempt,
|
|
928
|
+
maxAttempts: maxRetries,
|
|
929
|
+
first,
|
|
930
|
+
connectionStrategy,
|
|
931
|
+
durationMs: Date.now() - startedAt,
|
|
932
|
+
});
|
|
933
|
+
return result;
|
|
662
934
|
} catch (e) {
|
|
663
935
|
this.assertRunning();
|
|
664
|
-
|
|
936
|
+
const isTerminalError =
|
|
937
|
+
this.isHandleOwnershipError(e) ||
|
|
938
|
+
(e instanceof HardwareError &&
|
|
939
|
+
(e.errorCode === HardwareErrorCode.BridgeNeedsPermission ||
|
|
940
|
+
e.errorCode === HardwareErrorCode.WebUsbDeviceAccessError));
|
|
941
|
+
const willRetry = !isTerminalError && attempt < maxRetries;
|
|
942
|
+
this.logLifecycle('connect-attempt', {
|
|
943
|
+
phase: 'error',
|
|
944
|
+
attempt,
|
|
945
|
+
maxAttempts: maxRetries,
|
|
946
|
+
first,
|
|
947
|
+
connectionStrategy,
|
|
948
|
+
durationMs: Date.now() - startedAt,
|
|
949
|
+
willRetry,
|
|
950
|
+
...this.getDiagnosticError(e, path),
|
|
951
|
+
});
|
|
952
|
+
if (isTerminalError) {
|
|
665
953
|
throw e;
|
|
666
954
|
}
|
|
667
|
-
if (
|
|
955
|
+
if (!willRetry) {
|
|
668
956
|
throw e;
|
|
669
957
|
}
|
|
670
958
|
await wait(i * 200);
|
|
@@ -672,6 +960,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
672
960
|
}
|
|
673
961
|
}
|
|
674
962
|
|
|
963
|
+
private shouldUseLegacyV1Connection(path: string, options: WebUsbConnectOptions) {
|
|
964
|
+
if (options.expectedProtocol) return options.expectedProtocol === 'V1';
|
|
965
|
+
return !options.forceProtocolDetection && this.deviceProtocol.get(path) === 'V1';
|
|
966
|
+
}
|
|
967
|
+
|
|
675
968
|
/**
|
|
676
969
|
* Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
|
|
677
970
|
* Falls back to legacy hardcoded values if no vendor interface is found.
|
|
@@ -709,7 +1002,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
709
1002
|
* Connect to specific device.
|
|
710
1003
|
* Discovers interface/endpoint numbers from USB descriptors on first connection.
|
|
711
1004
|
*/
|
|
712
|
-
async connectToDevice(path: string, first: boolean) {
|
|
1005
|
+
async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
713
1006
|
this.assertRunning();
|
|
714
1007
|
let device: USBDevice = await this.findDevice(path);
|
|
715
1008
|
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
@@ -722,21 +1015,63 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
722
1015
|
if (!device.opened) {
|
|
723
1016
|
openingHandles.add(device);
|
|
724
1017
|
try {
|
|
725
|
-
await this.
|
|
1018
|
+
await this.runLoggedHandleOperation({
|
|
1019
|
+
operation: 'open',
|
|
1020
|
+
device,
|
|
1021
|
+
path,
|
|
1022
|
+
task: () => device.open(),
|
|
1023
|
+
});
|
|
726
1024
|
} catch (error) {
|
|
727
1025
|
return await this.throwWebUsbIoError(path, 'open', error);
|
|
728
1026
|
} finally {
|
|
729
1027
|
openingHandles.delete(device);
|
|
730
1028
|
}
|
|
731
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
if (this.shouldUseLegacyV1Connection(path, options)) {
|
|
1032
|
+
// Match the last V1-only WebUSB lifecycle from SDK 1.1.31. Legacy devices
|
|
1033
|
+
// configure before reset and keep the same USBDevice object afterwards.
|
|
1034
|
+
if (first) {
|
|
1035
|
+
await this.runLoggedHandleOperation({
|
|
1036
|
+
operation: 'selectConfiguration',
|
|
1037
|
+
device,
|
|
1038
|
+
path,
|
|
1039
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1040
|
+
});
|
|
1041
|
+
try {
|
|
1042
|
+
await this.runLoggedHandleOperation({
|
|
1043
|
+
operation: 'reset',
|
|
1044
|
+
device,
|
|
1045
|
+
path,
|
|
1046
|
+
task: () => device.reset(),
|
|
1047
|
+
});
|
|
1048
|
+
} catch {
|
|
1049
|
+
this.assertRunning();
|
|
1050
|
+
// Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Descriptor lookup is local-only and does not alter the 1.1.31 USB
|
|
1055
|
+
// operation order. Defaults remain available for legacy descriptors.
|
|
1056
|
+
const legacyEndpoints = this.discoverEndpoints(device);
|
|
1057
|
+
this.deviceEndpoints.set(path, legacyEndpoints);
|
|
1058
|
+
await this.claimDeviceInterface(path, device, legacyEndpoints);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
732
1062
|
// A V1 packet retry continues an in-flight exchange. Preserve its endpoint
|
|
733
1063
|
// state as in the legacy transport; probing and V2 recovery still reset.
|
|
734
1064
|
if (first || this.deviceProtocol.get(path) !== 'V1') {
|
|
735
1065
|
try {
|
|
736
|
-
await this.
|
|
737
|
-
|
|
1066
|
+
await this.runLoggedHandleOperation({
|
|
1067
|
+
operation: 'reset',
|
|
1068
|
+
device,
|
|
1069
|
+
path,
|
|
1070
|
+
task: () => device.reset(),
|
|
1071
|
+
});
|
|
1072
|
+
} catch {
|
|
738
1073
|
this.assertRunning();
|
|
739
|
-
|
|
1074
|
+
// Reset remains best-effort; runLoggedHandleOperation already recorded the error.
|
|
740
1075
|
}
|
|
741
1076
|
}
|
|
742
1077
|
await this.getConnectedDevices();
|
|
@@ -751,7 +1086,12 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
751
1086
|
if (!device.opened) {
|
|
752
1087
|
openingHandles.add(device);
|
|
753
1088
|
try {
|
|
754
|
-
await this.
|
|
1089
|
+
await this.runLoggedHandleOperation({
|
|
1090
|
+
operation: 'open',
|
|
1091
|
+
device,
|
|
1092
|
+
path,
|
|
1093
|
+
task: () => device.open(),
|
|
1094
|
+
});
|
|
755
1095
|
} catch (error) {
|
|
756
1096
|
return await this.throwWebUsbIoError(path, 'open', error);
|
|
757
1097
|
} finally {
|
|
@@ -764,19 +1104,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
764
1104
|
!device.configuration ||
|
|
765
1105
|
device.configuration.configurationValue !== this.configurationId
|
|
766
1106
|
) {
|
|
767
|
-
await this.
|
|
1107
|
+
await this.runLoggedHandleOperation({
|
|
1108
|
+
operation: 'selectConfiguration',
|
|
1109
|
+
device,
|
|
1110
|
+
path,
|
|
1111
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1112
|
+
});
|
|
768
1113
|
}
|
|
769
1114
|
|
|
770
1115
|
// Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
|
|
771
1116
|
const endpoints = this.discoverEndpoints(device);
|
|
772
1117
|
this.deviceEndpoints.set(path, endpoints);
|
|
1118
|
+
await this.claimDeviceInterface(path, device, endpoints);
|
|
1119
|
+
await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
|
|
1120
|
+
await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
|
|
773
1124
|
try {
|
|
774
|
-
await this.
|
|
1125
|
+
await this.runLoggedHandleOperation({
|
|
1126
|
+
operation: 'claimInterface',
|
|
1127
|
+
device,
|
|
1128
|
+
path,
|
|
1129
|
+
interfaceNumber: endpoints.interfaceNumber,
|
|
1130
|
+
task: () => device.claimInterface(endpoints.interfaceNumber),
|
|
1131
|
+
});
|
|
775
1132
|
} catch (error) {
|
|
776
1133
|
return this.throwWebUsbIoError(path, 'claimInterface', error);
|
|
777
1134
|
}
|
|
778
|
-
await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
|
|
779
|
-
await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
|
|
780
1135
|
}
|
|
781
1136
|
|
|
782
1137
|
private assertHandleAvailable(device: USBDevice) {
|
|
@@ -800,7 +1155,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
800
1155
|
private releaseNormalHandles(path: string) {
|
|
801
1156
|
if (this.stopped) return;
|
|
802
1157
|
for (const [device, ownedPath] of this.normalDevices) {
|
|
803
|
-
if (ownedPath === path) {
|
|
1158
|
+
if (ownedPath === path && !device.opened) {
|
|
1159
|
+
// Do not expose a native handle to another transport while close failed.
|
|
1160
|
+
// A later release/stop can retry cleanup through normalDevices.
|
|
804
1161
|
normalHandleOwners.delete(device);
|
|
805
1162
|
this.normalDevices.delete(device);
|
|
806
1163
|
}
|
|
@@ -844,36 +1201,83 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
844
1201
|
|
|
845
1202
|
private async closeOpenDevice(path: string) {
|
|
846
1203
|
if (this.stopped) return;
|
|
847
|
-
const
|
|
848
|
-
? this.identityProbeDevices.get(path)
|
|
849
|
-
|
|
850
|
-
|
|
1204
|
+
const devices = this.identityProbe
|
|
1205
|
+
? [this.identityProbeDevices.get(path)].filter(
|
|
1206
|
+
(device): device is USBDevice => device !== undefined
|
|
1207
|
+
)
|
|
1208
|
+
: [...this.normalDevices]
|
|
1209
|
+
.filter(([, ownedPath]) => ownedPath === path)
|
|
1210
|
+
.map(([device]) => device);
|
|
1211
|
+
let firstCloseError: unknown;
|
|
1212
|
+
for (const device of devices) {
|
|
1213
|
+
try {
|
|
1214
|
+
await this.closeDeviceHandle(path, device);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
firstCloseError ??= error;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (firstCloseError) {
|
|
1220
|
+
throw firstCloseError instanceof Error
|
|
1221
|
+
? firstCloseError
|
|
1222
|
+
: new Error(this.getErrorMessage(firstCloseError));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
private async closeDeviceHandle(path: string, current: USBDevice) {
|
|
1227
|
+
if (!current.opened) return;
|
|
851
1228
|
this.assertHandleAvailable(current);
|
|
852
1229
|
|
|
853
1230
|
const endpoints = this.deviceEndpoints.get(path);
|
|
854
1231
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1232
|
+
if (this.getInterfaceClaimed(current, ifaceNum) === false) {
|
|
1233
|
+
this.logSkippedHandleOperation(
|
|
1234
|
+
'releaseInterface',
|
|
1235
|
+
current,
|
|
1236
|
+
'interface-not-claimed',
|
|
1237
|
+
ifaceNum
|
|
1238
|
+
);
|
|
1239
|
+
} else {
|
|
1240
|
+
try {
|
|
1241
|
+
await this.runLoggedHandleOperation({
|
|
1242
|
+
operation: 'releaseInterface',
|
|
1243
|
+
device: current,
|
|
1244
|
+
path,
|
|
1245
|
+
interfaceNumber: ifaceNum,
|
|
1246
|
+
task: () => current.releaseInterface(ifaceNum),
|
|
1247
|
+
});
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
1250
|
+
}
|
|
859
1251
|
}
|
|
860
1252
|
this.assertHandleAvailable(current);
|
|
861
1253
|
try {
|
|
862
|
-
await this.
|
|
1254
|
+
await this.runLoggedHandleOperation({
|
|
1255
|
+
operation: 'close',
|
|
1256
|
+
device: current,
|
|
1257
|
+
path,
|
|
1258
|
+
task: () => current.close(),
|
|
1259
|
+
});
|
|
863
1260
|
} catch (error) {
|
|
864
1261
|
this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
|
|
865
|
-
|
|
1262
|
+
throw error;
|
|
866
1263
|
}
|
|
867
1264
|
}
|
|
868
1265
|
|
|
869
1266
|
private async clearEndpointHalt(
|
|
1267
|
+
path: string,
|
|
870
1268
|
device: USBDevice,
|
|
871
1269
|
direction: USBDirection,
|
|
872
1270
|
endpointNumber: number
|
|
873
1271
|
) {
|
|
874
1272
|
this.assertHandleAvailable(device);
|
|
875
1273
|
try {
|
|
876
|
-
await this.
|
|
1274
|
+
await this.runLoggedHandleOperation({
|
|
1275
|
+
operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
|
|
1276
|
+
device,
|
|
1277
|
+
path,
|
|
1278
|
+
endpointNumber,
|
|
1279
|
+
task: () => device.clearHalt(direction, endpointNumber),
|
|
1280
|
+
});
|
|
877
1281
|
} catch (error) {
|
|
878
1282
|
this.assertRunning();
|
|
879
1283
|
this.Log?.debug(
|
|
@@ -1030,13 +1434,33 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1030
1434
|
if (currentDevice.opened) {
|
|
1031
1435
|
const endpoints = this.deviceEndpoints.get(path);
|
|
1032
1436
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1437
|
+
if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
|
|
1438
|
+
this.logSkippedHandleOperation(
|
|
1439
|
+
'releaseInterface',
|
|
1440
|
+
currentDevice,
|
|
1441
|
+
'interface-not-claimed',
|
|
1442
|
+
ifaceNum
|
|
1443
|
+
);
|
|
1444
|
+
} else {
|
|
1445
|
+
try {
|
|
1446
|
+
await this.runLoggedHandleOperation({
|
|
1447
|
+
operation: 'releaseInterface',
|
|
1448
|
+
device: currentDevice,
|
|
1449
|
+
path,
|
|
1450
|
+
interfaceNumber: ifaceNum,
|
|
1451
|
+
task: () => currentDevice.releaseInterface(ifaceNum),
|
|
1452
|
+
});
|
|
1453
|
+
} catch (releaseError) {
|
|
1454
|
+
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
1455
|
+
}
|
|
1037
1456
|
}
|
|
1038
1457
|
this.assertHandleAvailable(currentDevice);
|
|
1039
|
-
await this.
|
|
1458
|
+
await this.runLoggedHandleOperation({
|
|
1459
|
+
operation: 'close',
|
|
1460
|
+
device: currentDevice,
|
|
1461
|
+
path,
|
|
1462
|
+
task: () => currentDevice.close(),
|
|
1463
|
+
});
|
|
1040
1464
|
}
|
|
1041
1465
|
} catch (closeError) {
|
|
1042
1466
|
this.assertRunning();
|
|
@@ -1401,10 +1825,23 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1401
1825
|
* Release device
|
|
1402
1826
|
*/
|
|
1403
1827
|
async release(path: string) {
|
|
1828
|
+
const startedAt = Date.now();
|
|
1829
|
+
this.logLifecycle('release', { phase: 'start' });
|
|
1404
1830
|
this.acquiredPaths.delete(path);
|
|
1405
1831
|
try {
|
|
1406
1832
|
await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
|
|
1407
1833
|
await this.closeOpenDevice(path);
|
|
1834
|
+
this.logLifecycle('release', {
|
|
1835
|
+
phase: 'success',
|
|
1836
|
+
durationMs: Date.now() - startedAt,
|
|
1837
|
+
});
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
this.logLifecycle('release', {
|
|
1840
|
+
phase: 'error',
|
|
1841
|
+
durationMs: Date.now() - startedAt,
|
|
1842
|
+
...this.getDiagnosticError(error, path),
|
|
1843
|
+
});
|
|
1844
|
+
throw error;
|
|
1408
1845
|
} finally {
|
|
1409
1846
|
this.releaseIdentityProbeHandle(path);
|
|
1410
1847
|
this.releaseNormalHandles(path);
|