@onekeyfe/hd-transport-web-device 1.2.8-alpha.104 → 1.2.8-alpha.106
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 +50 -2
- package/__tests__/webusb-protocol-cache.test.ts +241 -1
- package/dist/index.d.ts +20 -2
- package/dist/index.js +376 -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 +499 -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,12 +767,27 @@ 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 {
|
|
534
785
|
await this.closeOpenDevice(input.path);
|
|
786
|
+
} catch (cleanupError) {
|
|
787
|
+
this.logLifecycle('acquire-cleanup', {
|
|
788
|
+
phase: 'error',
|
|
789
|
+
...this.getDiagnosticError(cleanupError, input.path),
|
|
790
|
+
});
|
|
535
791
|
} finally {
|
|
536
792
|
this.releaseIdentityProbeHandle(input.path);
|
|
537
793
|
this.releaseNormalHandles(input.path);
|
|
@@ -653,18 +909,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
653
909
|
/**
|
|
654
910
|
* Connect to device with retry mechanism
|
|
655
911
|
*/
|
|
656
|
-
async connect(path: string, first: boolean) {
|
|
912
|
+
async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
657
913
|
const maxRetries = 5;
|
|
658
914
|
for (let i = 0; i < maxRetries; i++) {
|
|
659
915
|
this.assertRunning();
|
|
916
|
+
const attempt = i + 1;
|
|
917
|
+
const startedAt = Date.now();
|
|
918
|
+
const connectionStrategy = this.shouldUseLegacyV1Connection(path, options)
|
|
919
|
+
? 'protocol-v1-legacy-1.1.31'
|
|
920
|
+
: 'current';
|
|
921
|
+
this.logLifecycle('connect-attempt', {
|
|
922
|
+
phase: 'start',
|
|
923
|
+
attempt,
|
|
924
|
+
maxAttempts: maxRetries,
|
|
925
|
+
first,
|
|
926
|
+
connectionStrategy,
|
|
927
|
+
});
|
|
660
928
|
try {
|
|
661
|
-
|
|
929
|
+
const result = await this.connectToDevice(path, first, options);
|
|
930
|
+
this.logLifecycle('connect-attempt', {
|
|
931
|
+
phase: 'success',
|
|
932
|
+
attempt,
|
|
933
|
+
maxAttempts: maxRetries,
|
|
934
|
+
first,
|
|
935
|
+
connectionStrategy,
|
|
936
|
+
durationMs: Date.now() - startedAt,
|
|
937
|
+
});
|
|
938
|
+
return result;
|
|
662
939
|
} catch (e) {
|
|
663
940
|
this.assertRunning();
|
|
664
|
-
|
|
941
|
+
const isTerminalError =
|
|
942
|
+
this.isHandleOwnershipError(e) ||
|
|
943
|
+
(e instanceof HardwareError &&
|
|
944
|
+
(e.errorCode === HardwareErrorCode.BridgeNeedsPermission ||
|
|
945
|
+
e.errorCode === HardwareErrorCode.WebUsbDeviceAccessError));
|
|
946
|
+
const willRetry = !isTerminalError && attempt < maxRetries;
|
|
947
|
+
this.logLifecycle('connect-attempt', {
|
|
948
|
+
phase: 'error',
|
|
949
|
+
attempt,
|
|
950
|
+
maxAttempts: maxRetries,
|
|
951
|
+
first,
|
|
952
|
+
connectionStrategy,
|
|
953
|
+
durationMs: Date.now() - startedAt,
|
|
954
|
+
willRetry,
|
|
955
|
+
...this.getDiagnosticError(e, path),
|
|
956
|
+
});
|
|
957
|
+
if (isTerminalError) {
|
|
665
958
|
throw e;
|
|
666
959
|
}
|
|
667
|
-
if (
|
|
960
|
+
if (!willRetry) {
|
|
668
961
|
throw e;
|
|
669
962
|
}
|
|
670
963
|
await wait(i * 200);
|
|
@@ -672,6 +965,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
672
965
|
}
|
|
673
966
|
}
|
|
674
967
|
|
|
968
|
+
private shouldUseLegacyV1Connection(path: string, options: WebUsbConnectOptions) {
|
|
969
|
+
if (options.expectedProtocol) return options.expectedProtocol === 'V1';
|
|
970
|
+
return !options.forceProtocolDetection && this.deviceProtocol.get(path) === 'V1';
|
|
971
|
+
}
|
|
972
|
+
|
|
675
973
|
/**
|
|
676
974
|
* Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
|
|
677
975
|
* Falls back to legacy hardcoded values if no vendor interface is found.
|
|
@@ -709,7 +1007,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
709
1007
|
* Connect to specific device.
|
|
710
1008
|
* Discovers interface/endpoint numbers from USB descriptors on first connection.
|
|
711
1009
|
*/
|
|
712
|
-
async connectToDevice(path: string, first: boolean) {
|
|
1010
|
+
async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
713
1011
|
this.assertRunning();
|
|
714
1012
|
let device: USBDevice = await this.findDevice(path);
|
|
715
1013
|
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
@@ -722,21 +1020,63 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
722
1020
|
if (!device.opened) {
|
|
723
1021
|
openingHandles.add(device);
|
|
724
1022
|
try {
|
|
725
|
-
await this.
|
|
1023
|
+
await this.runLoggedHandleOperation({
|
|
1024
|
+
operation: 'open',
|
|
1025
|
+
device,
|
|
1026
|
+
path,
|
|
1027
|
+
task: () => device.open(),
|
|
1028
|
+
});
|
|
726
1029
|
} catch (error) {
|
|
727
1030
|
return await this.throwWebUsbIoError(path, 'open', error);
|
|
728
1031
|
} finally {
|
|
729
1032
|
openingHandles.delete(device);
|
|
730
1033
|
}
|
|
731
1034
|
}
|
|
1035
|
+
|
|
1036
|
+
if (this.shouldUseLegacyV1Connection(path, options)) {
|
|
1037
|
+
// Match the last V1-only WebUSB lifecycle from SDK 1.1.31. Legacy devices
|
|
1038
|
+
// configure before reset and keep the same USBDevice object afterwards.
|
|
1039
|
+
if (first) {
|
|
1040
|
+
await this.runLoggedHandleOperation({
|
|
1041
|
+
operation: 'selectConfiguration',
|
|
1042
|
+
device,
|
|
1043
|
+
path,
|
|
1044
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1045
|
+
});
|
|
1046
|
+
try {
|
|
1047
|
+
await this.runLoggedHandleOperation({
|
|
1048
|
+
operation: 'reset',
|
|
1049
|
+
device,
|
|
1050
|
+
path,
|
|
1051
|
+
task: () => device.reset(),
|
|
1052
|
+
});
|
|
1053
|
+
} catch {
|
|
1054
|
+
this.assertRunning();
|
|
1055
|
+
// Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Descriptor lookup is local-only and does not alter the 1.1.31 USB
|
|
1060
|
+
// operation order. Defaults remain available for legacy descriptors.
|
|
1061
|
+
const legacyEndpoints = this.discoverEndpoints(device);
|
|
1062
|
+
this.deviceEndpoints.set(path, legacyEndpoints);
|
|
1063
|
+
await this.claimDeviceInterface(path, device, legacyEndpoints);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
732
1067
|
// A V1 packet retry continues an in-flight exchange. Preserve its endpoint
|
|
733
1068
|
// state as in the legacy transport; probing and V2 recovery still reset.
|
|
734
1069
|
if (first || this.deviceProtocol.get(path) !== 'V1') {
|
|
735
1070
|
try {
|
|
736
|
-
await this.
|
|
737
|
-
|
|
1071
|
+
await this.runLoggedHandleOperation({
|
|
1072
|
+
operation: 'reset',
|
|
1073
|
+
device,
|
|
1074
|
+
path,
|
|
1075
|
+
task: () => device.reset(),
|
|
1076
|
+
});
|
|
1077
|
+
} catch {
|
|
738
1078
|
this.assertRunning();
|
|
739
|
-
|
|
1079
|
+
// Reset remains best-effort; runLoggedHandleOperation already recorded the error.
|
|
740
1080
|
}
|
|
741
1081
|
}
|
|
742
1082
|
await this.getConnectedDevices();
|
|
@@ -751,7 +1091,12 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
751
1091
|
if (!device.opened) {
|
|
752
1092
|
openingHandles.add(device);
|
|
753
1093
|
try {
|
|
754
|
-
await this.
|
|
1094
|
+
await this.runLoggedHandleOperation({
|
|
1095
|
+
operation: 'open',
|
|
1096
|
+
device,
|
|
1097
|
+
path,
|
|
1098
|
+
task: () => device.open(),
|
|
1099
|
+
});
|
|
755
1100
|
} catch (error) {
|
|
756
1101
|
return await this.throwWebUsbIoError(path, 'open', error);
|
|
757
1102
|
} finally {
|
|
@@ -764,19 +1109,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
764
1109
|
!device.configuration ||
|
|
765
1110
|
device.configuration.configurationValue !== this.configurationId
|
|
766
1111
|
) {
|
|
767
|
-
await this.
|
|
1112
|
+
await this.runLoggedHandleOperation({
|
|
1113
|
+
operation: 'selectConfiguration',
|
|
1114
|
+
device,
|
|
1115
|
+
path,
|
|
1116
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1117
|
+
});
|
|
768
1118
|
}
|
|
769
1119
|
|
|
770
1120
|
// Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
|
|
771
1121
|
const endpoints = this.discoverEndpoints(device);
|
|
772
1122
|
this.deviceEndpoints.set(path, endpoints);
|
|
1123
|
+
await this.claimDeviceInterface(path, device, endpoints);
|
|
1124
|
+
await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
|
|
1125
|
+
await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
|
|
773
1129
|
try {
|
|
774
|
-
await this.
|
|
1130
|
+
await this.runLoggedHandleOperation({
|
|
1131
|
+
operation: 'claimInterface',
|
|
1132
|
+
device,
|
|
1133
|
+
path,
|
|
1134
|
+
interfaceNumber: endpoints.interfaceNumber,
|
|
1135
|
+
task: () => device.claimInterface(endpoints.interfaceNumber),
|
|
1136
|
+
});
|
|
775
1137
|
} catch (error) {
|
|
776
1138
|
return this.throwWebUsbIoError(path, 'claimInterface', error);
|
|
777
1139
|
}
|
|
778
|
-
await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
|
|
779
|
-
await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
|
|
780
1140
|
}
|
|
781
1141
|
|
|
782
1142
|
private assertHandleAvailable(device: USBDevice) {
|
|
@@ -800,7 +1160,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
800
1160
|
private releaseNormalHandles(path: string) {
|
|
801
1161
|
if (this.stopped) return;
|
|
802
1162
|
for (const [device, ownedPath] of this.normalDevices) {
|
|
803
|
-
if (ownedPath === path) {
|
|
1163
|
+
if (ownedPath === path && !device.opened) {
|
|
1164
|
+
// Do not expose a native handle to another transport while close failed.
|
|
1165
|
+
// A later release/stop can retry cleanup through normalDevices.
|
|
804
1166
|
normalHandleOwners.delete(device);
|
|
805
1167
|
this.normalDevices.delete(device);
|
|
806
1168
|
}
|
|
@@ -844,36 +1206,83 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
844
1206
|
|
|
845
1207
|
private async closeOpenDevice(path: string) {
|
|
846
1208
|
if (this.stopped) return;
|
|
847
|
-
const
|
|
848
|
-
? this.identityProbeDevices.get(path)
|
|
849
|
-
|
|
850
|
-
|
|
1209
|
+
const devices = this.identityProbe
|
|
1210
|
+
? [this.identityProbeDevices.get(path)].filter(
|
|
1211
|
+
(device): device is USBDevice => device !== undefined
|
|
1212
|
+
)
|
|
1213
|
+
: [...this.normalDevices]
|
|
1214
|
+
.filter(([, ownedPath]) => ownedPath === path)
|
|
1215
|
+
.map(([device]) => device);
|
|
1216
|
+
let firstCloseError: unknown;
|
|
1217
|
+
for (const device of devices) {
|
|
1218
|
+
try {
|
|
1219
|
+
await this.closeDeviceHandle(path, device);
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
firstCloseError ??= error;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (firstCloseError) {
|
|
1225
|
+
throw firstCloseError instanceof Error
|
|
1226
|
+
? firstCloseError
|
|
1227
|
+
: new Error(this.getErrorMessage(firstCloseError));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
private async closeDeviceHandle(path: string, current: USBDevice) {
|
|
1232
|
+
if (!current.opened) return;
|
|
851
1233
|
this.assertHandleAvailable(current);
|
|
852
1234
|
|
|
853
1235
|
const endpoints = this.deviceEndpoints.get(path);
|
|
854
1236
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1237
|
+
if (this.getInterfaceClaimed(current, ifaceNum) === false) {
|
|
1238
|
+
this.logSkippedHandleOperation(
|
|
1239
|
+
'releaseInterface',
|
|
1240
|
+
current,
|
|
1241
|
+
'interface-not-claimed',
|
|
1242
|
+
ifaceNum
|
|
1243
|
+
);
|
|
1244
|
+
} else {
|
|
1245
|
+
try {
|
|
1246
|
+
await this.runLoggedHandleOperation({
|
|
1247
|
+
operation: 'releaseInterface',
|
|
1248
|
+
device: current,
|
|
1249
|
+
path,
|
|
1250
|
+
interfaceNumber: ifaceNum,
|
|
1251
|
+
task: () => current.releaseInterface(ifaceNum),
|
|
1252
|
+
});
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
1255
|
+
}
|
|
859
1256
|
}
|
|
860
1257
|
this.assertHandleAvailable(current);
|
|
861
1258
|
try {
|
|
862
|
-
await this.
|
|
1259
|
+
await this.runLoggedHandleOperation({
|
|
1260
|
+
operation: 'close',
|
|
1261
|
+
device: current,
|
|
1262
|
+
path,
|
|
1263
|
+
task: () => current.close(),
|
|
1264
|
+
});
|
|
863
1265
|
} catch (error) {
|
|
864
1266
|
this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
|
|
865
|
-
|
|
1267
|
+
throw error;
|
|
866
1268
|
}
|
|
867
1269
|
}
|
|
868
1270
|
|
|
869
1271
|
private async clearEndpointHalt(
|
|
1272
|
+
path: string,
|
|
870
1273
|
device: USBDevice,
|
|
871
1274
|
direction: USBDirection,
|
|
872
1275
|
endpointNumber: number
|
|
873
1276
|
) {
|
|
874
1277
|
this.assertHandleAvailable(device);
|
|
875
1278
|
try {
|
|
876
|
-
await this.
|
|
1279
|
+
await this.runLoggedHandleOperation({
|
|
1280
|
+
operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
|
|
1281
|
+
device,
|
|
1282
|
+
path,
|
|
1283
|
+
endpointNumber,
|
|
1284
|
+
task: () => device.clearHalt(direction, endpointNumber),
|
|
1285
|
+
});
|
|
877
1286
|
} catch (error) {
|
|
878
1287
|
this.assertRunning();
|
|
879
1288
|
this.Log?.debug(
|
|
@@ -1030,13 +1439,33 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1030
1439
|
if (currentDevice.opened) {
|
|
1031
1440
|
const endpoints = this.deviceEndpoints.get(path);
|
|
1032
1441
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1442
|
+
if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
|
|
1443
|
+
this.logSkippedHandleOperation(
|
|
1444
|
+
'releaseInterface',
|
|
1445
|
+
currentDevice,
|
|
1446
|
+
'interface-not-claimed',
|
|
1447
|
+
ifaceNum
|
|
1448
|
+
);
|
|
1449
|
+
} else {
|
|
1450
|
+
try {
|
|
1451
|
+
await this.runLoggedHandleOperation({
|
|
1452
|
+
operation: 'releaseInterface',
|
|
1453
|
+
device: currentDevice,
|
|
1454
|
+
path,
|
|
1455
|
+
interfaceNumber: ifaceNum,
|
|
1456
|
+
task: () => currentDevice.releaseInterface(ifaceNum),
|
|
1457
|
+
});
|
|
1458
|
+
} catch (releaseError) {
|
|
1459
|
+
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
1460
|
+
}
|
|
1037
1461
|
}
|
|
1038
1462
|
this.assertHandleAvailable(currentDevice);
|
|
1039
|
-
await this.
|
|
1463
|
+
await this.runLoggedHandleOperation({
|
|
1464
|
+
operation: 'close',
|
|
1465
|
+
device: currentDevice,
|
|
1466
|
+
path,
|
|
1467
|
+
task: () => currentDevice.close(),
|
|
1468
|
+
});
|
|
1040
1469
|
}
|
|
1041
1470
|
} catch (closeError) {
|
|
1042
1471
|
this.assertRunning();
|
|
@@ -1401,10 +1830,23 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1401
1830
|
* Release device
|
|
1402
1831
|
*/
|
|
1403
1832
|
async release(path: string) {
|
|
1833
|
+
const startedAt = Date.now();
|
|
1834
|
+
this.logLifecycle('release', { phase: 'start' });
|
|
1404
1835
|
this.acquiredPaths.delete(path);
|
|
1405
1836
|
try {
|
|
1406
1837
|
await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
|
|
1407
1838
|
await this.closeOpenDevice(path);
|
|
1839
|
+
this.logLifecycle('release', {
|
|
1840
|
+
phase: 'success',
|
|
1841
|
+
durationMs: Date.now() - startedAt,
|
|
1842
|
+
});
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
this.logLifecycle('release', {
|
|
1845
|
+
phase: 'error',
|
|
1846
|
+
durationMs: Date.now() - startedAt,
|
|
1847
|
+
...this.getDiagnosticError(error, path),
|
|
1848
|
+
});
|
|
1849
|
+
throw error;
|
|
1408
1850
|
} finally {
|
|
1409
1851
|
this.releaseIdentityProbeHandle(path);
|
|
1410
1852
|
this.releaseNormalHandles(path);
|