@onekeyfe/hd-transport-web-device 1.2.8-alpha.13 → 1.2.8-alpha.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/electron-ble-transport.test.ts +228 -7
- package/__tests__/webusb-identity-probe.test.ts +935 -0
- package/__tests__/webusb-protocol-cache.test.ts +301 -1
- package/__tests__/webusb-protocol-v2-timeout.test.ts +18 -5
- package/dist/electron-ble-transport.d.ts +4 -1
- package/dist/electron-ble-transport.d.ts.map +1 -1
- package/dist/index.d.ts +41 -4
- package/dist/index.js +688 -68
- package/dist/webusb.d.ts +38 -3
- package/dist/webusb.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/electron-ble-transport.ts +58 -18
- package/src/webusb.ts +758 -52
package/src/webusb.ts
CHANGED
|
@@ -44,6 +44,9 @@ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
|
|
|
44
44
|
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
|
+
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;
|
|
47
50
|
// Legacy devices can take longer to answer Initialize after a WebUSB reset.
|
|
48
51
|
// Keep this aligned with Node USB so a slow Protocol V1 response is not
|
|
49
52
|
// misreported as a protocol mismatch during acquire.
|
|
@@ -73,6 +76,23 @@ interface TransferCancelToken {
|
|
|
73
76
|
|
|
74
77
|
type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
75
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
|
+
|
|
76
96
|
/**
|
|
77
97
|
* The navigator.usb disconnect listener is module-scoped and attached at most
|
|
78
98
|
* once: listeners on navigator.usb are global and never garbage collected, so a
|
|
@@ -82,6 +102,13 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
|
82
102
|
*/
|
|
83
103
|
let activeWebUsbTransport: WebUsbTransport | undefined;
|
|
84
104
|
let usbDisconnectListenerAttached = false;
|
|
105
|
+
let nextWebUsbTransportInstanceId = 0;
|
|
106
|
+
// Only isolated discovery owns these handles. Normal calls must never close or
|
|
107
|
+
// open them, including while native open() is still pending.
|
|
108
|
+
const identityProbeHandles = new WeakSet<USBDevice>();
|
|
109
|
+
const openingHandles = new WeakSet<USBDevice>();
|
|
110
|
+
// A normal acquire keeps ownership even while recovery temporarily closes the handle.
|
|
111
|
+
const normalHandleOwners = new WeakMap<USBDevice, WebUsbTransport>();
|
|
85
112
|
|
|
86
113
|
function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
87
114
|
activeWebUsbTransport = instance;
|
|
@@ -100,6 +127,14 @@ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
|
100
127
|
}
|
|
101
128
|
|
|
102
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
|
+
|
|
103
138
|
messages: ReturnType<typeof transport.parseConfigure> | undefined;
|
|
104
139
|
|
|
105
140
|
/** Protobuf schema for Protocol V2 transports. */
|
|
@@ -124,6 +159,16 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
124
159
|
/** Paths currently acquired (between a successful acquire() and its release()). */
|
|
125
160
|
private acquiredPaths: Set<string> = new Set();
|
|
126
161
|
|
|
162
|
+
private identityProbeDevices = new Map<string, USBDevice>();
|
|
163
|
+
|
|
164
|
+
private normalDevices = new Map<USBDevice, string>();
|
|
165
|
+
|
|
166
|
+
private pendingHandleOperations = new Set<Promise<unknown>>();
|
|
167
|
+
|
|
168
|
+
private stopPromise?: Promise<void>;
|
|
169
|
+
|
|
170
|
+
private identityProbeCleanupPaths = new Set<string>();
|
|
171
|
+
|
|
127
172
|
/**
|
|
128
173
|
* The exact USBDevice object each cached protocol was probed against. The
|
|
129
174
|
* browser returns the same object identity for a device as long as it stays
|
|
@@ -160,7 +205,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
160
205
|
|
|
161
206
|
interfaceId = INTERFACE_ID;
|
|
162
207
|
|
|
163
|
-
constructor() {
|
|
208
|
+
constructor(private readonly identityProbe = false) {
|
|
164
209
|
super({
|
|
165
210
|
router: PROTOCOL_V2_CHANNEL_USB,
|
|
166
211
|
maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
|
|
@@ -183,7 +228,250 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
183
228
|
);
|
|
184
229
|
}
|
|
185
230
|
this.usb = usb;
|
|
186
|
-
registerActiveWebUsbTransport(this, usb);
|
|
231
|
+
if (!this.identityProbe) registerActiveWebUsbTransport(this, usb);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
stop(): Promise<void> {
|
|
235
|
+
const startedAt = Date.now();
|
|
236
|
+
this.stopped = true;
|
|
237
|
+
this.acquiredPaths.clear();
|
|
238
|
+
if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
|
|
239
|
+
if (!this.stopPromise) {
|
|
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
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return this.stopPromise;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async stopResources() {
|
|
264
|
+
let cleanupError: unknown;
|
|
265
|
+
try {
|
|
266
|
+
await this.disposeProtocolV2UsbLinks('WebUSB transport stopped');
|
|
267
|
+
} catch (error) {
|
|
268
|
+
cleanupError = error;
|
|
269
|
+
}
|
|
270
|
+
// Wait for late open/reset/claim/close completions before releasing ownership.
|
|
271
|
+
// Reads are aborted by closing their handle and reject late results below.
|
|
272
|
+
await Promise.allSettled([...this.pendingHandleOperations]);
|
|
273
|
+
const handles = this.identityProbe
|
|
274
|
+
? [...this.identityProbeDevices].map(([path, device]) => [device, path] as const)
|
|
275
|
+
: [...this.normalDevices];
|
|
276
|
+
for (const [device, path] of handles) {
|
|
277
|
+
if (!this.identityProbe && normalHandleOwners.get(device) !== this) {
|
|
278
|
+
this.normalDevices.delete(device);
|
|
279
|
+
} else {
|
|
280
|
+
try {
|
|
281
|
+
if (device.opened) {
|
|
282
|
+
const iface = this.deviceEndpoints.get(path)?.interfaceNumber ?? this.interfaceId;
|
|
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
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await this.runLoggedHandleOperation({
|
|
305
|
+
operation: 'close',
|
|
306
|
+
device,
|
|
307
|
+
path,
|
|
308
|
+
allowStopped: true,
|
|
309
|
+
task: () => device.close(),
|
|
310
|
+
});
|
|
311
|
+
if (device.opened) throw new Error('WebUSB handle remains open after stop');
|
|
312
|
+
}
|
|
313
|
+
if (this.identityProbe) {
|
|
314
|
+
identityProbeHandles.delete(device);
|
|
315
|
+
this.identityProbeDevices.delete(path);
|
|
316
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
317
|
+
} else {
|
|
318
|
+
normalHandleOwners.delete(device);
|
|
319
|
+
this.normalDevices.delete(device);
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
cleanupError ??= error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
this.deviceList = [];
|
|
327
|
+
this.deviceProtocol.clear();
|
|
328
|
+
this.confirmedDeviceProtocols.clear();
|
|
329
|
+
this.deviceProtocolHints.clear();
|
|
330
|
+
this.staleProtocolPaths.clear();
|
|
331
|
+
this.probedDeviceObjects.clear();
|
|
332
|
+
if (!this.normalDevices.size && !this.identityProbeDevices.size) {
|
|
333
|
+
this.deviceEndpoints.clear();
|
|
334
|
+
}
|
|
335
|
+
if (cleanupError) {
|
|
336
|
+
throw cleanupError instanceof Error
|
|
337
|
+
? cleanupError
|
|
338
|
+
: new Error(this.getErrorMessage(cleanupError));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private assertRunning() {
|
|
343
|
+
if (this.stopped) {
|
|
344
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'WebUSB transport is stopped');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async runHandleOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
349
|
+
this.assertRunning();
|
|
350
|
+
const pending = Promise.resolve(operation());
|
|
351
|
+
this.pendingHandleOperations.add(pending);
|
|
352
|
+
try {
|
|
353
|
+
const result = await pending;
|
|
354
|
+
this.assertRunning();
|
|
355
|
+
return result;
|
|
356
|
+
} finally {
|
|
357
|
+
this.pendingHandleOperations.delete(pending);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
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
|
+
}
|
|
187
475
|
}
|
|
188
476
|
|
|
189
477
|
/**
|
|
@@ -249,10 +537,22 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
249
537
|
*/
|
|
250
538
|
async promptDeviceAccess() {
|
|
251
539
|
if (!this.usb) return null;
|
|
540
|
+
const startedAt = Date.now();
|
|
541
|
+
this.logLifecycle('prompt-device-access', { phase: 'start' });
|
|
252
542
|
try {
|
|
253
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
|
+
});
|
|
254
549
|
return device;
|
|
255
550
|
} catch (e) {
|
|
551
|
+
this.logLifecycle('prompt-device-access', {
|
|
552
|
+
phase: 'error',
|
|
553
|
+
durationMs: Date.now() - startedAt,
|
|
554
|
+
...this.getDiagnosticError(e),
|
|
555
|
+
});
|
|
256
556
|
this.Log.debug(
|
|
257
557
|
'requestDevice error: ',
|
|
258
558
|
e instanceof Error ? `${e.name}: ${e.message}` : String(e)
|
|
@@ -267,17 +567,73 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
267
567
|
* It does NOT prompt the user to select a device
|
|
268
568
|
*/
|
|
269
569
|
async enumerate() {
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
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;
|
|
598
|
+
}
|
|
272
599
|
}
|
|
273
600
|
|
|
274
601
|
/**
|
|
275
602
|
* Get list of connected devices
|
|
276
603
|
*/
|
|
277
604
|
async getConnectedDevices() {
|
|
605
|
+
this.assertRunning();
|
|
278
606
|
if (!this.usb) return [];
|
|
279
607
|
|
|
280
|
-
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
|
+
}
|
|
626
|
+
this.assertRunning();
|
|
627
|
+
for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
|
|
628
|
+
if (owner?.stopped) {
|
|
629
|
+
try {
|
|
630
|
+
await owner.stop();
|
|
631
|
+
} catch (error) {
|
|
632
|
+
this.Log?.debug('[WebUsbTransport] stopped owner cleanup failed:', error);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
this.assertRunning();
|
|
281
637
|
const onekeyDevices = devices.filter(dev => {
|
|
282
638
|
const isOneKey = ONEKEY_WEBUSB_FILTER.some(
|
|
283
639
|
(desc: { vendorId: number; productId: number }) =>
|
|
@@ -310,11 +666,30 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
310
666
|
* Acquire device control
|
|
311
667
|
*/
|
|
312
668
|
async acquire(input: AcquireInput) {
|
|
669
|
+
this.assertRunning();
|
|
313
670
|
if (!input.path) return;
|
|
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
|
+
});
|
|
314
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
|
+
}
|
|
315
687
|
await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
|
|
316
688
|
await this.closeOpenDevice(input.path);
|
|
317
|
-
await this.connect(input.path ?? '', true
|
|
689
|
+
await this.connect(input.path ?? '', true, {
|
|
690
|
+
expectedProtocol: input.expectedProtocol,
|
|
691
|
+
forceProtocolDetection: input.forceProtocolDetection,
|
|
692
|
+
});
|
|
318
693
|
if (input.skipProtocolProbe) {
|
|
319
694
|
if (!input.expectedProtocol) {
|
|
320
695
|
throw ERRORS.TypedError(
|
|
@@ -383,18 +758,40 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
383
758
|
input.expectedProtocol,
|
|
384
759
|
protocolHint
|
|
385
760
|
);
|
|
761
|
+
this.assertRunning();
|
|
386
762
|
this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
|
|
387
763
|
const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
|
|
388
764
|
if (probedDevice) {
|
|
389
765
|
this.probedDeviceObjects.set(input.path, probedDevice);
|
|
390
766
|
}
|
|
391
767
|
}
|
|
768
|
+
this.assertRunning();
|
|
392
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
|
+
});
|
|
393
775
|
return await Promise.resolve(input.path);
|
|
394
776
|
} catch (e) {
|
|
777
|
+
this.logLifecycle('acquire', {
|
|
778
|
+
phase: 'error',
|
|
779
|
+
durationMs: Date.now() - startedAt,
|
|
780
|
+
...this.getDiagnosticError(e, input.path),
|
|
781
|
+
});
|
|
395
782
|
this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
|
396
783
|
this.acquiredPaths.delete(input.path);
|
|
397
|
-
|
|
784
|
+
try {
|
|
785
|
+
await this.closeOpenDevice(input.path);
|
|
786
|
+
} catch (cleanupError) {
|
|
787
|
+
this.logLifecycle('acquire-cleanup', {
|
|
788
|
+
phase: 'error',
|
|
789
|
+
...this.getDiagnosticError(cleanupError, input.path),
|
|
790
|
+
});
|
|
791
|
+
} finally {
|
|
792
|
+
this.releaseIdentityProbeHandle(input.path);
|
|
793
|
+
this.releaseNormalHandles(input.path);
|
|
794
|
+
}
|
|
398
795
|
throw e;
|
|
399
796
|
}
|
|
400
797
|
}
|
|
@@ -405,21 +802,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
405
802
|
* fall back to a Protocol V2 Ping probe.
|
|
406
803
|
*/
|
|
407
804
|
private createProtocolMismatchError(expected: ProtocolType) {
|
|
408
|
-
return ERRORS.TypedError(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
);
|
|
805
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
|
|
806
|
+
expectedProtocol: expected,
|
|
807
|
+
});
|
|
412
808
|
}
|
|
413
809
|
|
|
414
810
|
private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
|
|
415
|
-
return ERRORS.TypedError(
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
);
|
|
811
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
|
|
812
|
+
expectedProtocol: expected,
|
|
813
|
+
attempts,
|
|
814
|
+
});
|
|
419
815
|
}
|
|
420
816
|
|
|
421
817
|
private createProtocolDetectionError() {
|
|
422
|
-
return ERRORS.TypedError(HardwareErrorCode.
|
|
818
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
|
|
423
819
|
}
|
|
424
820
|
|
|
425
821
|
private async detectProtocol(
|
|
@@ -513,16 +909,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
513
909
|
/**
|
|
514
910
|
* Connect to device with retry mechanism
|
|
515
911
|
*/
|
|
516
|
-
async connect(path: string, first: boolean) {
|
|
912
|
+
async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
517
913
|
const maxRetries = 5;
|
|
518
914
|
for (let i = 0; i < maxRetries; i++) {
|
|
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
|
+
});
|
|
519
928
|
try {
|
|
520
|
-
|
|
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;
|
|
521
939
|
} catch (e) {
|
|
522
|
-
|
|
940
|
+
this.assertRunning();
|
|
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) {
|
|
523
958
|
throw e;
|
|
524
959
|
}
|
|
525
|
-
if (
|
|
960
|
+
if (!willRetry) {
|
|
526
961
|
throw e;
|
|
527
962
|
}
|
|
528
963
|
await wait(i * 200);
|
|
@@ -530,6 +965,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
530
965
|
}
|
|
531
966
|
}
|
|
532
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
|
+
|
|
533
973
|
/**
|
|
534
974
|
* Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
|
|
535
975
|
* Falls back to legacy hardcoded values if no vendor interface is found.
|
|
@@ -567,31 +1007,106 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
567
1007
|
* Connect to specific device.
|
|
568
1008
|
* Discovers interface/endpoint numbers from USB descriptors on first connection.
|
|
569
1009
|
*/
|
|
570
|
-
async connectToDevice(path: string, first: boolean) {
|
|
1010
|
+
async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
1011
|
+
this.assertRunning();
|
|
571
1012
|
let device: USBDevice = await this.findDevice(path);
|
|
1013
|
+
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
1014
|
+
await this.closeOpenDevice(path);
|
|
1015
|
+
this.releaseIdentityProbeHandle(path);
|
|
1016
|
+
this.claimIdentityProbeHandle(path, device);
|
|
1017
|
+
}
|
|
1018
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
1019
|
+
this.assertHandleAvailable(device);
|
|
572
1020
|
if (!device.opened) {
|
|
1021
|
+
openingHandles.add(device);
|
|
573
1022
|
try {
|
|
574
|
-
await
|
|
1023
|
+
await this.runLoggedHandleOperation({
|
|
1024
|
+
operation: 'open',
|
|
1025
|
+
device,
|
|
1026
|
+
path,
|
|
1027
|
+
task: () => device.open(),
|
|
1028
|
+
});
|
|
575
1029
|
} catch (error) {
|
|
576
|
-
return this.throwWebUsbIoError(path, 'open', error);
|
|
1030
|
+
return await this.throwWebUsbIoError(path, 'open', error);
|
|
1031
|
+
} finally {
|
|
1032
|
+
openingHandles.delete(device);
|
|
1033
|
+
}
|
|
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 (
|
|
1040
|
+
first ||
|
|
1041
|
+
!device.configuration ||
|
|
1042
|
+
device.configuration.configurationValue !== this.configurationId
|
|
1043
|
+
) {
|
|
1044
|
+
await this.runLoggedHandleOperation({
|
|
1045
|
+
operation: 'selectConfiguration',
|
|
1046
|
+
device,
|
|
1047
|
+
path,
|
|
1048
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
if (first) {
|
|
1052
|
+
try {
|
|
1053
|
+
await this.runLoggedHandleOperation({
|
|
1054
|
+
operation: 'reset',
|
|
1055
|
+
device,
|
|
1056
|
+
path,
|
|
1057
|
+
task: () => device.reset(),
|
|
1058
|
+
});
|
|
1059
|
+
} catch {
|
|
1060
|
+
this.assertRunning();
|
|
1061
|
+
// Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
|
|
1062
|
+
}
|
|
577
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// Descriptor lookup is local-only and does not alter the 1.1.31 USB
|
|
1066
|
+
// operation order. Defaults remain available for legacy descriptors.
|
|
1067
|
+
const legacyEndpoints = this.discoverEndpoints(device);
|
|
1068
|
+
this.deviceEndpoints.set(path, legacyEndpoints);
|
|
1069
|
+
await this.claimDeviceInterface(path, device, legacyEndpoints);
|
|
1070
|
+
return;
|
|
578
1071
|
}
|
|
1072
|
+
|
|
579
1073
|
// A V1 packet retry continues an in-flight exchange. Preserve its endpoint
|
|
580
1074
|
// state as in the legacy transport; probing and V2 recovery still reset.
|
|
581
1075
|
if (first || this.deviceProtocol.get(path) !== 'V1') {
|
|
582
1076
|
try {
|
|
583
|
-
await
|
|
584
|
-
|
|
585
|
-
|
|
1077
|
+
await this.runLoggedHandleOperation({
|
|
1078
|
+
operation: 'reset',
|
|
1079
|
+
device,
|
|
1080
|
+
path,
|
|
1081
|
+
task: () => device.reset(),
|
|
1082
|
+
});
|
|
1083
|
+
} catch {
|
|
1084
|
+
this.assertRunning();
|
|
1085
|
+
// Reset remains best-effort; runLoggedHandleOperation already recorded the error.
|
|
586
1086
|
}
|
|
587
1087
|
}
|
|
588
1088
|
await this.getConnectedDevices();
|
|
589
1089
|
device = await this.findDevice(path);
|
|
1090
|
+
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
1091
|
+
await this.closeOpenDevice(path);
|
|
1092
|
+
this.releaseIdentityProbeHandle(path);
|
|
1093
|
+
this.claimIdentityProbeHandle(path, device);
|
|
1094
|
+
}
|
|
1095
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
1096
|
+
this.assertHandleAvailable(device);
|
|
590
1097
|
if (!device.opened) {
|
|
1098
|
+
openingHandles.add(device);
|
|
591
1099
|
try {
|
|
592
|
-
await
|
|
1100
|
+
await this.runLoggedHandleOperation({
|
|
1101
|
+
operation: 'open',
|
|
1102
|
+
device,
|
|
1103
|
+
path,
|
|
1104
|
+
task: () => device.open(),
|
|
1105
|
+
});
|
|
593
1106
|
} catch (error) {
|
|
594
|
-
return this.throwWebUsbIoError(path, 'open', error);
|
|
1107
|
+
return await this.throwWebUsbIoError(path, 'open', error);
|
|
1108
|
+
} finally {
|
|
1109
|
+
openingHandles.delete(device);
|
|
595
1110
|
}
|
|
596
1111
|
}
|
|
597
1112
|
|
|
@@ -600,47 +1115,182 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
600
1115
|
!device.configuration ||
|
|
601
1116
|
device.configuration.configurationValue !== this.configurationId
|
|
602
1117
|
) {
|
|
603
|
-
await
|
|
1118
|
+
await this.runLoggedHandleOperation({
|
|
1119
|
+
operation: 'selectConfiguration',
|
|
1120
|
+
device,
|
|
1121
|
+
path,
|
|
1122
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1123
|
+
});
|
|
604
1124
|
}
|
|
605
1125
|
|
|
606
1126
|
// Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
|
|
607
1127
|
const endpoints = this.discoverEndpoints(device);
|
|
608
1128
|
this.deviceEndpoints.set(path, endpoints);
|
|
1129
|
+
await this.claimDeviceInterface(path, device, endpoints);
|
|
1130
|
+
await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
|
|
1131
|
+
await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
|
|
609
1135
|
try {
|
|
610
|
-
await
|
|
1136
|
+
await this.runLoggedHandleOperation({
|
|
1137
|
+
operation: 'claimInterface',
|
|
1138
|
+
device,
|
|
1139
|
+
path,
|
|
1140
|
+
interfaceNumber: endpoints.interfaceNumber,
|
|
1141
|
+
task: () => device.claimInterface(endpoints.interfaceNumber),
|
|
1142
|
+
});
|
|
611
1143
|
} catch (error) {
|
|
612
1144
|
return this.throwWebUsbIoError(path, 'claimInterface', error);
|
|
613
1145
|
}
|
|
614
|
-
|
|
615
|
-
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private assertHandleAvailable(device: USBDevice) {
|
|
1149
|
+
this.assertRunning();
|
|
1150
|
+
const normalOwner = normalHandleOwners.get(device);
|
|
1151
|
+
if (
|
|
1152
|
+
(normalOwner && normalOwner !== this) ||
|
|
1153
|
+
(identityProbeHandles.has(device) &&
|
|
1154
|
+
![...this.identityProbeDevices.values()].includes(device))
|
|
1155
|
+
) {
|
|
1156
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
private claimNormalHandle(path: string, device: USBDevice) {
|
|
1161
|
+
this.assertHandleAvailable(device);
|
|
1162
|
+
normalHandleOwners.set(device, this);
|
|
1163
|
+
this.normalDevices.set(device, path);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private releaseNormalHandles(path: string) {
|
|
1167
|
+
if (this.stopped) return;
|
|
1168
|
+
for (const [device, ownedPath] of this.normalDevices) {
|
|
1169
|
+
if (ownedPath === path && !device.opened) {
|
|
1170
|
+
// Do not expose a native handle to another transport while close failed.
|
|
1171
|
+
// A later release/stop can retry cleanup through normalDevices.
|
|
1172
|
+
normalHandleOwners.delete(device);
|
|
1173
|
+
this.normalDevices.delete(device);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private isHandleOwnershipError(error: unknown): error is HardwareError {
|
|
1179
|
+
return (
|
|
1180
|
+
error instanceof HardwareError &&
|
|
1181
|
+
error.errorCode === HardwareErrorCode.RuntimeError &&
|
|
1182
|
+
this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private claimIdentityProbeHandle(path: string, device: USBDevice) {
|
|
1187
|
+
this.assertRunning();
|
|
1188
|
+
if (
|
|
1189
|
+
device.opened ||
|
|
1190
|
+
openingHandles.has(device) ||
|
|
1191
|
+
identityProbeHandles.has(device) ||
|
|
1192
|
+
normalHandleOwners.has(device)
|
|
1193
|
+
) {
|
|
1194
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
1195
|
+
}
|
|
1196
|
+
identityProbeHandles.add(device);
|
|
1197
|
+
this.identityProbeDevices.set(path, device);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
private releaseIdentityProbeHandle(path: string) {
|
|
1201
|
+
if (this.stopped) return;
|
|
1202
|
+
const device = this.identityProbeDevices.get(path);
|
|
1203
|
+
// A failed native close must not make an open probe handle available to other callers.
|
|
1204
|
+
if (device?.opened) {
|
|
1205
|
+
this.identityProbeCleanupPaths.add(path);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (device) identityProbeHandles.delete(device);
|
|
1209
|
+
this.identityProbeDevices.delete(path);
|
|
1210
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
616
1211
|
}
|
|
617
1212
|
|
|
618
1213
|
private async closeOpenDevice(path: string) {
|
|
619
|
-
|
|
620
|
-
|
|
1214
|
+
if (this.stopped) return;
|
|
1215
|
+
const devices = this.identityProbe
|
|
1216
|
+
? [this.identityProbeDevices.get(path)].filter(
|
|
1217
|
+
(device): device is USBDevice => device !== undefined
|
|
1218
|
+
)
|
|
1219
|
+
: [...this.normalDevices]
|
|
1220
|
+
.filter(([, ownedPath]) => ownedPath === path)
|
|
1221
|
+
.map(([device]) => device);
|
|
1222
|
+
let firstCloseError: unknown;
|
|
1223
|
+
for (const device of devices) {
|
|
1224
|
+
try {
|
|
1225
|
+
await this.closeDeviceHandle(path, device);
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
firstCloseError ??= error;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (firstCloseError) {
|
|
1231
|
+
throw firstCloseError instanceof Error
|
|
1232
|
+
? firstCloseError
|
|
1233
|
+
: new Error(this.getErrorMessage(firstCloseError));
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private async closeDeviceHandle(path: string, current: USBDevice) {
|
|
1238
|
+
if (!current.opened) return;
|
|
1239
|
+
this.assertHandleAvailable(current);
|
|
621
1240
|
|
|
622
1241
|
const endpoints = this.deviceEndpoints.get(path);
|
|
623
1242
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1243
|
+
if (this.getInterfaceClaimed(current, ifaceNum) === false) {
|
|
1244
|
+
this.logSkippedHandleOperation(
|
|
1245
|
+
'releaseInterface',
|
|
1246
|
+
current,
|
|
1247
|
+
'interface-not-claimed',
|
|
1248
|
+
ifaceNum
|
|
1249
|
+
);
|
|
1250
|
+
} else {
|
|
1251
|
+
try {
|
|
1252
|
+
await this.runLoggedHandleOperation({
|
|
1253
|
+
operation: 'releaseInterface',
|
|
1254
|
+
device: current,
|
|
1255
|
+
path,
|
|
1256
|
+
interfaceNumber: ifaceNum,
|
|
1257
|
+
task: () => current.releaseInterface(ifaceNum),
|
|
1258
|
+
});
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
1261
|
+
}
|
|
628
1262
|
}
|
|
1263
|
+
this.assertHandleAvailable(current);
|
|
629
1264
|
try {
|
|
630
|
-
await
|
|
1265
|
+
await this.runLoggedHandleOperation({
|
|
1266
|
+
operation: 'close',
|
|
1267
|
+
device: current,
|
|
1268
|
+
path,
|
|
1269
|
+
task: () => current.close(),
|
|
1270
|
+
});
|
|
631
1271
|
} catch (error) {
|
|
632
1272
|
this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
|
|
1273
|
+
throw error;
|
|
633
1274
|
}
|
|
634
1275
|
}
|
|
635
1276
|
|
|
636
1277
|
private async clearEndpointHalt(
|
|
1278
|
+
path: string,
|
|
637
1279
|
device: USBDevice,
|
|
638
1280
|
direction: USBDirection,
|
|
639
1281
|
endpointNumber: number
|
|
640
1282
|
) {
|
|
1283
|
+
this.assertHandleAvailable(device);
|
|
641
1284
|
try {
|
|
642
|
-
await
|
|
1285
|
+
await this.runLoggedHandleOperation({
|
|
1286
|
+
operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
|
|
1287
|
+
device,
|
|
1288
|
+
path,
|
|
1289
|
+
endpointNumber,
|
|
1290
|
+
task: () => device.clearHalt(direction, endpointNumber),
|
|
1291
|
+
});
|
|
643
1292
|
} catch (error) {
|
|
1293
|
+
this.assertRunning();
|
|
644
1294
|
this.Log?.debug(
|
|
645
1295
|
`[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
|
|
646
1296
|
error
|
|
@@ -656,6 +1306,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
656
1306
|
* protocol entry produced the same fail-fast).
|
|
657
1307
|
*/
|
|
658
1308
|
private assertAcquired(path: string) {
|
|
1309
|
+
this.assertRunning();
|
|
659
1310
|
if (!this.acquiredPaths.has(path)) {
|
|
660
1311
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
|
|
661
1312
|
}
|
|
@@ -713,7 +1364,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
713
1364
|
operation: WebUsbOperation,
|
|
714
1365
|
error: unknown
|
|
715
1366
|
): Promise<never> {
|
|
716
|
-
|
|
1367
|
+
this.assertRunning();
|
|
1368
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
|
|
717
1369
|
if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
|
|
718
1370
|
|
|
719
1371
|
const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
|
|
@@ -753,7 +1405,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
753
1405
|
}
|
|
754
1406
|
|
|
755
1407
|
private isRetryablePacketIoError(error: unknown) {
|
|
756
|
-
if (this.
|
|
1408
|
+
if (this.stopped) return false;
|
|
1409
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
|
|
757
1410
|
const message = this.getErrorMessage(error).toLowerCase();
|
|
758
1411
|
if (this.isWebUsbPermissionDeniedMessage(message)) return false;
|
|
759
1412
|
return (
|
|
@@ -784,20 +1437,45 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
784
1437
|
// USB disconnect event itself was missed.
|
|
785
1438
|
this.staleProtocolPaths.add(path);
|
|
786
1439
|
await wait(attempt * PACKET_IO_RETRY_DELAY);
|
|
1440
|
+
this.assertRunning();
|
|
787
1441
|
|
|
788
1442
|
try {
|
|
789
1443
|
const currentDevice = await this.findDevice(path);
|
|
1444
|
+
this.assertHandleAvailable(currentDevice);
|
|
790
1445
|
if (currentDevice.opened) {
|
|
791
1446
|
const endpoints = this.deviceEndpoints.get(path);
|
|
792
1447
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1448
|
+
if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
|
|
1449
|
+
this.logSkippedHandleOperation(
|
|
1450
|
+
'releaseInterface',
|
|
1451
|
+
currentDevice,
|
|
1452
|
+
'interface-not-claimed',
|
|
1453
|
+
ifaceNum
|
|
1454
|
+
);
|
|
1455
|
+
} else {
|
|
1456
|
+
try {
|
|
1457
|
+
await this.runLoggedHandleOperation({
|
|
1458
|
+
operation: 'releaseInterface',
|
|
1459
|
+
device: currentDevice,
|
|
1460
|
+
path,
|
|
1461
|
+
interfaceNumber: ifaceNum,
|
|
1462
|
+
task: () => currentDevice.releaseInterface(ifaceNum),
|
|
1463
|
+
});
|
|
1464
|
+
} catch (releaseError) {
|
|
1465
|
+
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
1466
|
+
}
|
|
797
1467
|
}
|
|
798
|
-
|
|
1468
|
+
this.assertHandleAvailable(currentDevice);
|
|
1469
|
+
await this.runLoggedHandleOperation({
|
|
1470
|
+
operation: 'close',
|
|
1471
|
+
device: currentDevice,
|
|
1472
|
+
path,
|
|
1473
|
+
task: () => currentDevice.close(),
|
|
1474
|
+
});
|
|
799
1475
|
}
|
|
800
1476
|
} catch (closeError) {
|
|
1477
|
+
this.assertRunning();
|
|
1478
|
+
if (this.isHandleOwnershipError(closeError)) throw closeError;
|
|
801
1479
|
this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
|
|
802
1480
|
}
|
|
803
1481
|
|
|
@@ -839,6 +1517,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
839
1517
|
try {
|
|
840
1518
|
await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
|
|
841
1519
|
} catch (reconnectError) {
|
|
1520
|
+
this.assertRunning();
|
|
1521
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
842
1522
|
lastError = reconnectError;
|
|
843
1523
|
this.Log.debug(
|
|
844
1524
|
`[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -861,7 +1541,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
861
1541
|
const transferBuffer = this.toArrayBuffer(
|
|
862
1542
|
packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
|
|
863
1543
|
);
|
|
1544
|
+
this.assertHandleAvailable(device);
|
|
864
1545
|
const result = await device.transferOut(endpointOut, transferBuffer);
|
|
1546
|
+
this.assertHandleAvailable(device);
|
|
865
1547
|
if (result.status !== 'ok') {
|
|
866
1548
|
return this.throwWebUsbIoError(
|
|
867
1549
|
path,
|
|
@@ -888,7 +1570,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
888
1570
|
}
|
|
889
1571
|
const endpoints = this.deviceEndpoints.get(path);
|
|
890
1572
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1573
|
+
this.assertHandleAvailable(device);
|
|
891
1574
|
const result = await device.transferIn(endpointIn, length);
|
|
1575
|
+
this.assertHandleAvailable(device);
|
|
892
1576
|
return this.getTransferInData(result);
|
|
893
1577
|
} catch (error) {
|
|
894
1578
|
lastError = error;
|
|
@@ -902,6 +1586,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
902
1586
|
try {
|
|
903
1587
|
await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
|
|
904
1588
|
} catch (reconnectError) {
|
|
1589
|
+
this.assertRunning();
|
|
1590
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
905
1591
|
lastError = reconnectError;
|
|
906
1592
|
this.Log.debug(
|
|
907
1593
|
`[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -921,7 +1607,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
921
1607
|
}
|
|
922
1608
|
const endpoints = this.deviceEndpoints.get(path);
|
|
923
1609
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1610
|
+
this.assertHandleAvailable(device);
|
|
924
1611
|
const result = await device.transferIn(endpointIn, length);
|
|
1612
|
+
this.assertHandleAvailable(device);
|
|
925
1613
|
return this.getTransferInData(result);
|
|
926
1614
|
}
|
|
927
1615
|
|
|
@@ -984,15 +1672,15 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
984
1672
|
}
|
|
985
1673
|
|
|
986
1674
|
try {
|
|
987
|
-
await this.callProtocolV1(
|
|
1675
|
+
const response = await this.callProtocolV1(
|
|
988
1676
|
path,
|
|
989
|
-
'Initialize',
|
|
1677
|
+
this.identityProbe ? 'GetFeatures' : 'Initialize',
|
|
990
1678
|
{},
|
|
991
1679
|
{
|
|
992
1680
|
timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
|
|
993
1681
|
}
|
|
994
1682
|
);
|
|
995
|
-
return
|
|
1683
|
+
return !this.identityProbe || response.type === 'Features';
|
|
996
1684
|
} catch (error) {
|
|
997
1685
|
if (this.isWebUsbIoHardwareError(error)) throw error;
|
|
998
1686
|
return false;
|
|
@@ -1148,9 +1836,27 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1148
1836
|
* Release device
|
|
1149
1837
|
*/
|
|
1150
1838
|
async release(path: string) {
|
|
1839
|
+
const startedAt = Date.now();
|
|
1840
|
+
this.logLifecycle('release', { phase: 'start' });
|
|
1151
1841
|
this.acquiredPaths.delete(path);
|
|
1152
|
-
|
|
1153
|
-
|
|
1842
|
+
try {
|
|
1843
|
+
await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
|
|
1844
|
+
await this.closeOpenDevice(path);
|
|
1845
|
+
this.logLifecycle('release', {
|
|
1846
|
+
phase: 'success',
|
|
1847
|
+
durationMs: Date.now() - startedAt,
|
|
1848
|
+
});
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
this.logLifecycle('release', {
|
|
1851
|
+
phase: 'error',
|
|
1852
|
+
durationMs: Date.now() - startedAt,
|
|
1853
|
+
...this.getDiagnosticError(error, path),
|
|
1854
|
+
});
|
|
1855
|
+
throw error;
|
|
1856
|
+
} finally {
|
|
1857
|
+
this.releaseIdentityProbeHandle(path);
|
|
1858
|
+
this.releaseNormalHandles(path);
|
|
1859
|
+
}
|
|
1154
1860
|
// Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
|
|
1155
1861
|
// physical-device property, so the next acquire can skip the wire-level probe.
|
|
1156
1862
|
// V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link
|