@onekeyfe/hd-transport-web-device 1.2.8-alpha.101 → 1.2.8-alpha.102
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-protocol-cache.test.ts +197 -1
- package/dist/index.d.ts +20 -2
- package/dist/index.js +377 -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 +496 -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,65 @@ 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
|
+
const legacyEndpoints = {
|
|
1055
|
+
interfaceNumber: this.interfaceId,
|
|
1056
|
+
endpointIn: this.endpointId,
|
|
1057
|
+
endpointOut: this.endpointId,
|
|
1058
|
+
};
|
|
1059
|
+
this.deviceEndpoints.set(path, legacyEndpoints);
|
|
1060
|
+
await this.claimDeviceInterface(path, device, legacyEndpoints);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
732
1064
|
// A V1 packet retry continues an in-flight exchange. Preserve its endpoint
|
|
733
1065
|
// state as in the legacy transport; probing and V2 recovery still reset.
|
|
734
1066
|
if (first || this.deviceProtocol.get(path) !== 'V1') {
|
|
735
1067
|
try {
|
|
736
|
-
await this.
|
|
737
|
-
|
|
1068
|
+
await this.runLoggedHandleOperation({
|
|
1069
|
+
operation: 'reset',
|
|
1070
|
+
device,
|
|
1071
|
+
path,
|
|
1072
|
+
task: () => device.reset(),
|
|
1073
|
+
});
|
|
1074
|
+
} catch {
|
|
738
1075
|
this.assertRunning();
|
|
739
|
-
|
|
1076
|
+
// Reset remains best-effort; runLoggedHandleOperation already recorded the error.
|
|
740
1077
|
}
|
|
741
1078
|
}
|
|
742
1079
|
await this.getConnectedDevices();
|
|
@@ -751,7 +1088,12 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
751
1088
|
if (!device.opened) {
|
|
752
1089
|
openingHandles.add(device);
|
|
753
1090
|
try {
|
|
754
|
-
await this.
|
|
1091
|
+
await this.runLoggedHandleOperation({
|
|
1092
|
+
operation: 'open',
|
|
1093
|
+
device,
|
|
1094
|
+
path,
|
|
1095
|
+
task: () => device.open(),
|
|
1096
|
+
});
|
|
755
1097
|
} catch (error) {
|
|
756
1098
|
return await this.throwWebUsbIoError(path, 'open', error);
|
|
757
1099
|
} finally {
|
|
@@ -764,19 +1106,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
764
1106
|
!device.configuration ||
|
|
765
1107
|
device.configuration.configurationValue !== this.configurationId
|
|
766
1108
|
) {
|
|
767
|
-
await this.
|
|
1109
|
+
await this.runLoggedHandleOperation({
|
|
1110
|
+
operation: 'selectConfiguration',
|
|
1111
|
+
device,
|
|
1112
|
+
path,
|
|
1113
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1114
|
+
});
|
|
768
1115
|
}
|
|
769
1116
|
|
|
770
1117
|
// Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
|
|
771
1118
|
const endpoints = this.discoverEndpoints(device);
|
|
772
1119
|
this.deviceEndpoints.set(path, endpoints);
|
|
1120
|
+
await this.claimDeviceInterface(path, device, endpoints);
|
|
1121
|
+
await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
|
|
1122
|
+
await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
|
|
773
1126
|
try {
|
|
774
|
-
await this.
|
|
1127
|
+
await this.runLoggedHandleOperation({
|
|
1128
|
+
operation: 'claimInterface',
|
|
1129
|
+
device,
|
|
1130
|
+
path,
|
|
1131
|
+
interfaceNumber: endpoints.interfaceNumber,
|
|
1132
|
+
task: () => device.claimInterface(endpoints.interfaceNumber),
|
|
1133
|
+
});
|
|
775
1134
|
} catch (error) {
|
|
776
1135
|
return this.throwWebUsbIoError(path, 'claimInterface', error);
|
|
777
1136
|
}
|
|
778
|
-
await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
|
|
779
|
-
await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
|
|
780
1137
|
}
|
|
781
1138
|
|
|
782
1139
|
private assertHandleAvailable(device: USBDevice) {
|
|
@@ -800,7 +1157,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
800
1157
|
private releaseNormalHandles(path: string) {
|
|
801
1158
|
if (this.stopped) return;
|
|
802
1159
|
for (const [device, ownedPath] of this.normalDevices) {
|
|
803
|
-
if (ownedPath === path) {
|
|
1160
|
+
if (ownedPath === path && !device.opened) {
|
|
1161
|
+
// Do not expose a native handle to another transport while close failed.
|
|
1162
|
+
// A later release/stop can retry cleanup through normalDevices.
|
|
804
1163
|
normalHandleOwners.delete(device);
|
|
805
1164
|
this.normalDevices.delete(device);
|
|
806
1165
|
}
|
|
@@ -844,36 +1203,83 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
844
1203
|
|
|
845
1204
|
private async closeOpenDevice(path: string) {
|
|
846
1205
|
if (this.stopped) return;
|
|
847
|
-
const
|
|
848
|
-
? this.identityProbeDevices.get(path)
|
|
849
|
-
|
|
850
|
-
|
|
1206
|
+
const devices = this.identityProbe
|
|
1207
|
+
? [this.identityProbeDevices.get(path)].filter(
|
|
1208
|
+
(device): device is USBDevice => device !== undefined
|
|
1209
|
+
)
|
|
1210
|
+
: [...this.normalDevices]
|
|
1211
|
+
.filter(([, ownedPath]) => ownedPath === path)
|
|
1212
|
+
.map(([device]) => device);
|
|
1213
|
+
let firstCloseError: unknown;
|
|
1214
|
+
for (const device of devices) {
|
|
1215
|
+
try {
|
|
1216
|
+
await this.closeDeviceHandle(path, device);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
firstCloseError ??= error;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (firstCloseError) {
|
|
1222
|
+
throw firstCloseError instanceof Error
|
|
1223
|
+
? firstCloseError
|
|
1224
|
+
: new Error(this.getErrorMessage(firstCloseError));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private async closeDeviceHandle(path: string, current: USBDevice) {
|
|
1229
|
+
if (!current.opened) return;
|
|
851
1230
|
this.assertHandleAvailable(current);
|
|
852
1231
|
|
|
853
1232
|
const endpoints = this.deviceEndpoints.get(path);
|
|
854
1233
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1234
|
+
if (this.getInterfaceClaimed(current, ifaceNum) === false) {
|
|
1235
|
+
this.logSkippedHandleOperation(
|
|
1236
|
+
'releaseInterface',
|
|
1237
|
+
current,
|
|
1238
|
+
'interface-not-claimed',
|
|
1239
|
+
ifaceNum
|
|
1240
|
+
);
|
|
1241
|
+
} else {
|
|
1242
|
+
try {
|
|
1243
|
+
await this.runLoggedHandleOperation({
|
|
1244
|
+
operation: 'releaseInterface',
|
|
1245
|
+
device: current,
|
|
1246
|
+
path,
|
|
1247
|
+
interfaceNumber: ifaceNum,
|
|
1248
|
+
task: () => current.releaseInterface(ifaceNum),
|
|
1249
|
+
});
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
1252
|
+
}
|
|
859
1253
|
}
|
|
860
1254
|
this.assertHandleAvailable(current);
|
|
861
1255
|
try {
|
|
862
|
-
await this.
|
|
1256
|
+
await this.runLoggedHandleOperation({
|
|
1257
|
+
operation: 'close',
|
|
1258
|
+
device: current,
|
|
1259
|
+
path,
|
|
1260
|
+
task: () => current.close(),
|
|
1261
|
+
});
|
|
863
1262
|
} catch (error) {
|
|
864
1263
|
this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
|
|
865
|
-
|
|
1264
|
+
throw error;
|
|
866
1265
|
}
|
|
867
1266
|
}
|
|
868
1267
|
|
|
869
1268
|
private async clearEndpointHalt(
|
|
1269
|
+
path: string,
|
|
870
1270
|
device: USBDevice,
|
|
871
1271
|
direction: USBDirection,
|
|
872
1272
|
endpointNumber: number
|
|
873
1273
|
) {
|
|
874
1274
|
this.assertHandleAvailable(device);
|
|
875
1275
|
try {
|
|
876
|
-
await this.
|
|
1276
|
+
await this.runLoggedHandleOperation({
|
|
1277
|
+
operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
|
|
1278
|
+
device,
|
|
1279
|
+
path,
|
|
1280
|
+
endpointNumber,
|
|
1281
|
+
task: () => device.clearHalt(direction, endpointNumber),
|
|
1282
|
+
});
|
|
877
1283
|
} catch (error) {
|
|
878
1284
|
this.assertRunning();
|
|
879
1285
|
this.Log?.debug(
|
|
@@ -1030,13 +1436,33 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1030
1436
|
if (currentDevice.opened) {
|
|
1031
1437
|
const endpoints = this.deviceEndpoints.get(path);
|
|
1032
1438
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1439
|
+
if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
|
|
1440
|
+
this.logSkippedHandleOperation(
|
|
1441
|
+
'releaseInterface',
|
|
1442
|
+
currentDevice,
|
|
1443
|
+
'interface-not-claimed',
|
|
1444
|
+
ifaceNum
|
|
1445
|
+
);
|
|
1446
|
+
} else {
|
|
1447
|
+
try {
|
|
1448
|
+
await this.runLoggedHandleOperation({
|
|
1449
|
+
operation: 'releaseInterface',
|
|
1450
|
+
device: currentDevice,
|
|
1451
|
+
path,
|
|
1452
|
+
interfaceNumber: ifaceNum,
|
|
1453
|
+
task: () => currentDevice.releaseInterface(ifaceNum),
|
|
1454
|
+
});
|
|
1455
|
+
} catch (releaseError) {
|
|
1456
|
+
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
1457
|
+
}
|
|
1037
1458
|
}
|
|
1038
1459
|
this.assertHandleAvailable(currentDevice);
|
|
1039
|
-
await this.
|
|
1460
|
+
await this.runLoggedHandleOperation({
|
|
1461
|
+
operation: 'close',
|
|
1462
|
+
device: currentDevice,
|
|
1463
|
+
path,
|
|
1464
|
+
task: () => currentDevice.close(),
|
|
1465
|
+
});
|
|
1040
1466
|
}
|
|
1041
1467
|
} catch (closeError) {
|
|
1042
1468
|
this.assertRunning();
|
|
@@ -1401,10 +1827,23 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1401
1827
|
* Release device
|
|
1402
1828
|
*/
|
|
1403
1829
|
async release(path: string) {
|
|
1830
|
+
const startedAt = Date.now();
|
|
1831
|
+
this.logLifecycle('release', { phase: 'start' });
|
|
1404
1832
|
this.acquiredPaths.delete(path);
|
|
1405
1833
|
try {
|
|
1406
1834
|
await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
|
|
1407
1835
|
await this.closeOpenDevice(path);
|
|
1836
|
+
this.logLifecycle('release', {
|
|
1837
|
+
phase: 'success',
|
|
1838
|
+
durationMs: Date.now() - startedAt,
|
|
1839
|
+
});
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
this.logLifecycle('release', {
|
|
1842
|
+
phase: 'error',
|
|
1843
|
+
durationMs: Date.now() - startedAt,
|
|
1844
|
+
...this.getDiagnosticError(error, path),
|
|
1845
|
+
});
|
|
1846
|
+
throw error;
|
|
1408
1847
|
} finally {
|
|
1409
1848
|
this.releaseIdentityProbeHandle(path);
|
|
1410
1849
|
this.releaseNormalHandles(path);
|