@ukeyfe/hardware-transport-web-device 1.1.13
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/README.md +29 -0
- package/dist/electron-ble-transport.d.ts +44 -0
- package/dist/electron-ble-transport.d.ts.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +552 -0
- package/dist/webusb.d.ts +33 -0
- package/dist/webusb.d.ts.map +1 -0
- package/package.json +31 -0
- package/src/electron-ble-transport.ts +413 -0
- package/src/index.ts +4 -0
- package/src/webusb.ts +298 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import transport, { COMMON_HEADER_SIZE, LogBlockCommand } from '@ukeyfe/hardware-transport';
|
|
2
|
+
import {
|
|
3
|
+
ERRORS,
|
|
4
|
+
HardwareErrorCode,
|
|
5
|
+
HardwareErrorCodeMessage,
|
|
6
|
+
Deferred,
|
|
7
|
+
createDeferred,
|
|
8
|
+
isHeaderChunk,
|
|
9
|
+
} from '@ukeyfe/hardware-shared';
|
|
10
|
+
import type EventEmitter from 'events';
|
|
11
|
+
// Import DesktopAPI type from hd-transport-electron
|
|
12
|
+
import type { DesktopAPI } from '@ukeyfe/hardware-transport-electron';
|
|
13
|
+
|
|
14
|
+
const { parseConfigure, buildBuffers, receiveOne, check } = transport;
|
|
15
|
+
|
|
16
|
+
// Noble BLE specific API interface
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
desktopApi?: DesktopAPI;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type BleAcquireInput = {
|
|
24
|
+
uuid: string;
|
|
25
|
+
forceCleanRunPromise?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Packet processing result interface
|
|
29
|
+
interface PacketProcessResult {
|
|
30
|
+
isComplete: boolean;
|
|
31
|
+
completePacket?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default class ElectronBleTransport {
|
|
36
|
+
_messages: ReturnType<typeof transport.parseConfigure> | undefined;
|
|
37
|
+
|
|
38
|
+
name = 'ElectronBleTransport';
|
|
39
|
+
|
|
40
|
+
configured = false;
|
|
41
|
+
|
|
42
|
+
runPromise: Deferred<any> | null = null;
|
|
43
|
+
|
|
44
|
+
Log?: any;
|
|
45
|
+
|
|
46
|
+
emitter?: EventEmitter;
|
|
47
|
+
|
|
48
|
+
// Cache for connected devices
|
|
49
|
+
private connectedDevices: Set<string> = new Set();
|
|
50
|
+
|
|
51
|
+
// Data processing state
|
|
52
|
+
private dataBuffers: Map<string, { buffer: number[]; bufferLength: number }> = new Map();
|
|
53
|
+
|
|
54
|
+
// Notification cleanup functions
|
|
55
|
+
private notificationCleanups: Map<string, () => void> = new Map();
|
|
56
|
+
|
|
57
|
+
// Disconnect listener cleanup functions
|
|
58
|
+
private disconnectCleanups: Map<string, () => void> = new Map();
|
|
59
|
+
|
|
60
|
+
// Handle bluetooth related errors with proper error code mapping
|
|
61
|
+
private handleBluetoothError(error: any): never {
|
|
62
|
+
if (error && typeof error === 'object') {
|
|
63
|
+
// Check for specific bluetooth error codes
|
|
64
|
+
if ('code' in error) {
|
|
65
|
+
if (error.code === HardwareErrorCode.BlePoweredOff) {
|
|
66
|
+
throw ERRORS.TypedError(HardwareErrorCode.BlePoweredOff);
|
|
67
|
+
}
|
|
68
|
+
if (error.code === HardwareErrorCode.BleUnsupported) {
|
|
69
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleUnsupported);
|
|
70
|
+
}
|
|
71
|
+
if (error.code === HardwareErrorCode.BlePermissionError) {
|
|
72
|
+
throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Check for error message containing bluetooth state related text using predefined messages
|
|
76
|
+
const errorMessage = error.message || String(error);
|
|
77
|
+
const poweredOffMessage = HardwareErrorCodeMessage[HardwareErrorCode.BlePoweredOff];
|
|
78
|
+
const unsupportedMessage = HardwareErrorCodeMessage[HardwareErrorCode.BleUnsupported];
|
|
79
|
+
const permissionMessage = HardwareErrorCodeMessage[HardwareErrorCode.BlePermissionError];
|
|
80
|
+
|
|
81
|
+
if (errorMessage.includes(poweredOffMessage) || errorMessage.includes('poweredOff')) {
|
|
82
|
+
throw ERRORS.TypedError(HardwareErrorCode.BlePoweredOff);
|
|
83
|
+
}
|
|
84
|
+
if (errorMessage.includes(unsupportedMessage) || errorMessage.includes('unsupported')) {
|
|
85
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleUnsupported);
|
|
86
|
+
}
|
|
87
|
+
if (errorMessage.includes(permissionMessage) || errorMessage.includes('unauthorized')) {
|
|
88
|
+
throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clean up all device state and listeners - unified cleanup function
|
|
96
|
+
private cleanupDeviceState(deviceId: string): void {
|
|
97
|
+
this.connectedDevices.delete(deviceId);
|
|
98
|
+
this.dataBuffers.delete(deviceId);
|
|
99
|
+
|
|
100
|
+
// Clean up notification listener
|
|
101
|
+
const notifyCleanup = this.notificationCleanups.get(deviceId);
|
|
102
|
+
if (notifyCleanup) {
|
|
103
|
+
notifyCleanup();
|
|
104
|
+
this.notificationCleanups.delete(deviceId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Clean up disconnect listener
|
|
108
|
+
const disconnectCleanup = this.disconnectCleanups.get(deviceId);
|
|
109
|
+
if (disconnectCleanup) {
|
|
110
|
+
disconnectCleanup();
|
|
111
|
+
this.disconnectCleanups.delete(deviceId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
init(logger: any, emitter?: EventEmitter) {
|
|
116
|
+
this.Log = logger;
|
|
117
|
+
this.emitter = emitter;
|
|
118
|
+
|
|
119
|
+
// Check if Noble BLE API is available
|
|
120
|
+
if (!window.desktopApi?.nobleBle) {
|
|
121
|
+
throw ERRORS.TypedError(
|
|
122
|
+
HardwareErrorCode.RuntimeError,
|
|
123
|
+
'Noble BLE API is not available. Please ensure you are running in Electron with Noble support.'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.Log?.debug('[Transport] Noble BLE Transport initialized');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
configure(signedData: any) {
|
|
131
|
+
const messages = parseConfigure(signedData);
|
|
132
|
+
this.configured = true;
|
|
133
|
+
this._messages = messages;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
listen() {}
|
|
137
|
+
|
|
138
|
+
async enumerate(): Promise<{ id: string; name: string }[]> {
|
|
139
|
+
try {
|
|
140
|
+
if (!window.desktopApi?.nobleBle) {
|
|
141
|
+
throw new Error('Noble BLE API not available');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const devices = await window.desktopApi.nobleBle.enumerate();
|
|
145
|
+
return devices;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
this.Log?.error('[Transport] Noble BLE enumerate failed:', error);
|
|
148
|
+
this.handleBluetoothError(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async acquire(input: BleAcquireInput) {
|
|
153
|
+
const { uuid, forceCleanRunPromise } = input;
|
|
154
|
+
|
|
155
|
+
if (!uuid) {
|
|
156
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Force clean running Promise
|
|
160
|
+
if (forceCleanRunPromise && this.runPromise) {
|
|
161
|
+
this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
if (!window.desktopApi?.nobleBle) {
|
|
166
|
+
throw new Error('Noble BLE API not available');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if device is available
|
|
170
|
+
const device = await window.desktopApi.nobleBle.getDevice(uuid);
|
|
171
|
+
if (!device) {
|
|
172
|
+
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${uuid} not found`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Connect to device
|
|
176
|
+
try {
|
|
177
|
+
await window.desktopApi.nobleBle.connect(uuid);
|
|
178
|
+
this.connectedDevices.add(uuid);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.handleBluetoothError(error);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Initialize data buffer for this device
|
|
184
|
+
this.dataBuffers.set(uuid, { buffer: [], bufferLength: 0 });
|
|
185
|
+
|
|
186
|
+
// Subscribe to notifications
|
|
187
|
+
await window.desktopApi.nobleBle.subscribe(uuid);
|
|
188
|
+
|
|
189
|
+
// Set up notification listener
|
|
190
|
+
const cleanup = window.desktopApi.nobleBle.onNotification(
|
|
191
|
+
(deviceId: string, data: string) => {
|
|
192
|
+
if (deviceId === uuid) {
|
|
193
|
+
this.handleNotificationData(uuid, data);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
this.notificationCleanups.set(uuid, cleanup);
|
|
198
|
+
|
|
199
|
+
// Set up disconnect listener
|
|
200
|
+
const disconnectCleanup = window.desktopApi.nobleBle.onDeviceDisconnected(
|
|
201
|
+
(disconnectedDevice: any) => {
|
|
202
|
+
if (disconnectedDevice.id === uuid) {
|
|
203
|
+
this.cleanupDeviceState(uuid);
|
|
204
|
+
|
|
205
|
+
// Trigger disconnect event
|
|
206
|
+
this.emitter?.emit('device-disconnect', {
|
|
207
|
+
name: disconnectedDevice.name,
|
|
208
|
+
id: disconnectedDevice.id,
|
|
209
|
+
connectId: disconnectedDevice.id,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
this.disconnectCleanups.set(uuid, disconnectCleanup);
|
|
215
|
+
|
|
216
|
+
// Trigger connect event
|
|
217
|
+
this.emitter?.emit('device-connect', {
|
|
218
|
+
name: device.name,
|
|
219
|
+
id: device.id,
|
|
220
|
+
connectId: device.id,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return { uuid, path: uuid };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.Log?.error('[Transport] Noble BLE acquire failed:', error);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async release(id: string) {
|
|
231
|
+
try {
|
|
232
|
+
if (this.connectedDevices.has(id)) {
|
|
233
|
+
// Unsubscribe from notifications
|
|
234
|
+
if (window.desktopApi?.nobleBle) {
|
|
235
|
+
await window.desktopApi.nobleBle.unsubscribe(id);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Disconnect device
|
|
239
|
+
if (window.desktopApi?.nobleBle) {
|
|
240
|
+
await window.desktopApi.nobleBle.disconnect(id);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Clean up all device state
|
|
244
|
+
this.cleanupDeviceState(id);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
this.Log?.error('[Transport] Noble BLE release failed:', error);
|
|
248
|
+
// Clean up local state even if release fails
|
|
249
|
+
this.cleanupDeviceState(id);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Handle notification data from Noble BLE
|
|
254
|
+
private handleNotificationData(deviceId: string, hexData: string): void {
|
|
255
|
+
// Check for pairing rejection
|
|
256
|
+
if (hexData === 'PAIRING_REJECTED') {
|
|
257
|
+
this.Log?.debug('[Transport] Pairing rejection detected for device:', deviceId);
|
|
258
|
+
if (this.runPromise) {
|
|
259
|
+
this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled));
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = this.processNotificationPacket(deviceId, hexData);
|
|
265
|
+
|
|
266
|
+
if (result.error) {
|
|
267
|
+
this.Log?.error('[Transport] Packet processing error:', result.error);
|
|
268
|
+
if (this.runPromise) {
|
|
269
|
+
this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (result.isComplete && result.completePacket) {
|
|
275
|
+
if (this.runPromise) {
|
|
276
|
+
this.runPromise.resolve(result.completePacket);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async call(uuid: string, name: string, data: Record<string, unknown>) {
|
|
282
|
+
if (this._messages == null) {
|
|
283
|
+
throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const forceRun = name === 'Initialize' || name === 'Cancel';
|
|
287
|
+
|
|
288
|
+
if (this.runPromise && !forceRun) {
|
|
289
|
+
throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!this.connectedDevices.has(uuid)) {
|
|
293
|
+
throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound, `Device ${uuid} not connected`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.runPromise = createDeferred();
|
|
297
|
+
const messages = this._messages;
|
|
298
|
+
|
|
299
|
+
// Log different types of commands appropriately
|
|
300
|
+
if (name === 'ResourceUpdate' || name === 'ResourceAck') {
|
|
301
|
+
this.Log?.debug('[Transport] Noble BLE call', 'name:', name, 'data:', {
|
|
302
|
+
file_name: data?.file_name,
|
|
303
|
+
hash: data?.hash,
|
|
304
|
+
});
|
|
305
|
+
} else if (LogBlockCommand.has(name)) {
|
|
306
|
+
this.Log?.debug('[Transport] Noble BLE call', 'name:', name);
|
|
307
|
+
} else {
|
|
308
|
+
this.Log?.debug('[Transport] Noble BLE call', 'name:', name, 'data:', data);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const buffers = buildBuffers(messages, name, data);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
if (!window.desktopApi?.nobleBle) {
|
|
315
|
+
throw new Error('Noble BLE write API not available');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Write each buffer to the device
|
|
319
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
320
|
+
const buffer = buffers[i];
|
|
321
|
+
|
|
322
|
+
if (!buffer || typeof buffer.toString !== 'function') {
|
|
323
|
+
this.Log?.error(`[Transport] Noble BLE buffer ${i + 1} is invalid:`, buffer);
|
|
324
|
+
throw new Error(`Buffer ${i + 1} is invalid`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Use ByteBuffer's toString('hex') method directly, similar to other transports
|
|
328
|
+
const hexString = buffer.toString('hex');
|
|
329
|
+
|
|
330
|
+
if (hexString.length === 0) {
|
|
331
|
+
this.Log?.error(`[Transport] Noble BLE buffer ${i + 1} generated empty hex string`);
|
|
332
|
+
throw new Error(`Buffer ${i + 1} is empty`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await window.desktopApi.nobleBle.write(uuid, hexString);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Wait for response
|
|
339
|
+
const response = await this.runPromise.promise;
|
|
340
|
+
|
|
341
|
+
if (typeof response !== 'string') {
|
|
342
|
+
throw new Error('Returning data is not string.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const jsonData = receiveOne(messages, response);
|
|
346
|
+
return check.call(jsonData);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
this.Log?.error('[Transport] Noble BLE call error:', e);
|
|
349
|
+
throw e;
|
|
350
|
+
} finally {
|
|
351
|
+
this.runPromise = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Process hex data from notification with validation and packet reassembly
|
|
356
|
+
private processNotificationPacket(deviceId: string, hexData: string): PacketProcessResult {
|
|
357
|
+
try {
|
|
358
|
+
// Validate input
|
|
359
|
+
if (typeof hexData !== 'string') {
|
|
360
|
+
return { isComplete: false, error: 'Invalid hexData type' };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Clean and validate hex format
|
|
364
|
+
const cleanHexData = hexData.replace(/\s+/g, '');
|
|
365
|
+
if (!/^[0-9A-Fa-f]*$/.test(cleanHexData)) {
|
|
366
|
+
return { isComplete: false, error: 'Invalid hex data format' };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Convert hex string to Uint8Array
|
|
370
|
+
const hexMatch = cleanHexData.match(/.{1,2}/g);
|
|
371
|
+
if (!hexMatch) {
|
|
372
|
+
return { isComplete: false, error: 'Failed to parse hex data' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const data = new Uint8Array(hexMatch.map(byte => parseInt(byte, 16)));
|
|
376
|
+
|
|
377
|
+
// Get buffer state
|
|
378
|
+
const bufferState = this.dataBuffers.get(deviceId);
|
|
379
|
+
if (!bufferState) {
|
|
380
|
+
return { isComplete: false, error: 'No buffer state for device' };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Process header or data chunk
|
|
384
|
+
if (isHeaderChunk(data)) {
|
|
385
|
+
const dataView = new DataView(data.buffer);
|
|
386
|
+
bufferState.bufferLength = dataView.getInt32(5, false);
|
|
387
|
+
bufferState.buffer = [...data.subarray(3)];
|
|
388
|
+
} else {
|
|
389
|
+
bufferState.buffer = bufferState.buffer.concat([...data]);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Check if packet is complete
|
|
393
|
+
if (bufferState.buffer.length - COMMON_HEADER_SIZE >= bufferState.bufferLength) {
|
|
394
|
+
const completeBuffer = new Uint8Array(bufferState.buffer);
|
|
395
|
+
|
|
396
|
+
// Reset buffer state
|
|
397
|
+
bufferState.bufferLength = 0;
|
|
398
|
+
bufferState.buffer = [];
|
|
399
|
+
|
|
400
|
+
// Convert to hex string
|
|
401
|
+
const hexString = Array.from(completeBuffer)
|
|
402
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
403
|
+
.join('');
|
|
404
|
+
|
|
405
|
+
return { isComplete: true, completePacket: hexString };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { isComplete: false };
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return { isComplete: false, error: `Packet processing error: ${error}` };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
package/src/index.ts
ADDED
package/src/webusb.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/* eslint-disable no-undef */
|
|
2
|
+
import transport, { AcquireInput, LogBlockCommand } from '@ukeyfe/hardware-transport';
|
|
3
|
+
import { ERRORS, HardwareErrorCode, UKEY_WEBUSB_FILTER, wait } from '@ukeyfe/hardware-shared';
|
|
4
|
+
import ByteBuffer from 'bytebuffer';
|
|
5
|
+
|
|
6
|
+
const { parseConfigure, buildEncodeBuffers, decodeProtocol, receiveOne, check } = transport;
|
|
7
|
+
|
|
8
|
+
const CONFIGURATION_ID = 1;
|
|
9
|
+
const INTERFACE_ID = 0;
|
|
10
|
+
const ENDPOINT_ID = 1;
|
|
11
|
+
const PACKET_SIZE = 64;
|
|
12
|
+
const HEADER_LENGTH = 6;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Device information with path and WebUSB device instance
|
|
16
|
+
*/
|
|
17
|
+
interface DeviceInfo {
|
|
18
|
+
path: string;
|
|
19
|
+
device: USBDevice;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default class WebUsbTransport {
|
|
23
|
+
messages: ReturnType<typeof transport.parseConfigure> | undefined;
|
|
24
|
+
|
|
25
|
+
name = 'WebUsbTransport';
|
|
26
|
+
|
|
27
|
+
stopped = false;
|
|
28
|
+
|
|
29
|
+
configured = false;
|
|
30
|
+
|
|
31
|
+
Log?: any;
|
|
32
|
+
|
|
33
|
+
usb?: USB;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cached list of connected devices
|
|
37
|
+
* This is essential for maintaining device references between operations
|
|
38
|
+
*/
|
|
39
|
+
deviceList: Array<DeviceInfo> = [];
|
|
40
|
+
|
|
41
|
+
configurationId = CONFIGURATION_ID;
|
|
42
|
+
|
|
43
|
+
endpointId = ENDPOINT_ID;
|
|
44
|
+
|
|
45
|
+
interfaceId = INTERFACE_ID;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Initialize WebUSB transport
|
|
49
|
+
*/
|
|
50
|
+
init(logger: any) {
|
|
51
|
+
this.Log = logger;
|
|
52
|
+
|
|
53
|
+
const { usb } = navigator;
|
|
54
|
+
if (!usb) {
|
|
55
|
+
throw ERRORS.TypedError(
|
|
56
|
+
HardwareErrorCode.RuntimeError,
|
|
57
|
+
'WebUSB is not supported by current browsers'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
this.usb = usb;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Configure transport protocol
|
|
65
|
+
*/
|
|
66
|
+
configure(signedData: any) {
|
|
67
|
+
const messages = parseConfigure(signedData);
|
|
68
|
+
this.configured = true;
|
|
69
|
+
this.messages = messages;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Request user to select a device
|
|
74
|
+
* This method must be called in response to a user action
|
|
75
|
+
* to comply with WebUSB security requirements
|
|
76
|
+
*/
|
|
77
|
+
async promptDeviceAccess() {
|
|
78
|
+
if (!this.usb) return null;
|
|
79
|
+
try {
|
|
80
|
+
const device = await this.usb.requestDevice({ filters: UKEY_WEBUSB_FILTER });
|
|
81
|
+
return device;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
this.Log.debug('requestDevice error: ', e);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Enumerate already connected devices
|
|
90
|
+
* This method only returns devices that are already authorized by the browser
|
|
91
|
+
* It does NOT prompt the user to select a device
|
|
92
|
+
*/
|
|
93
|
+
async enumerate() {
|
|
94
|
+
await this.getConnectedDevices();
|
|
95
|
+
return this.deviceList;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get list of connected devices
|
|
100
|
+
*/
|
|
101
|
+
async getConnectedDevices() {
|
|
102
|
+
if (!this.usb) return [];
|
|
103
|
+
|
|
104
|
+
const devices = await this.usb.getDevices();
|
|
105
|
+
const ukeyDevices = devices.filter(dev => {
|
|
106
|
+
const isUKey = UKEY_WEBUSB_FILTER.some(
|
|
107
|
+
desc => dev.vendorId === desc.vendorId && dev.productId === desc.productId
|
|
108
|
+
);
|
|
109
|
+
const hasSerialNumber = typeof dev.serialNumber === 'string' && dev.serialNumber.length > 0;
|
|
110
|
+
return isUKey && hasSerialNumber;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.deviceList = ukeyDevices.map(device => ({
|
|
114
|
+
path: device.serialNumber as string,
|
|
115
|
+
device,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
return this.deviceList;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Acquire device control
|
|
123
|
+
*/
|
|
124
|
+
async acquire(input: AcquireInput) {
|
|
125
|
+
if (!input.path) return;
|
|
126
|
+
try {
|
|
127
|
+
await this.connect(input.path ?? '', true);
|
|
128
|
+
return await Promise.resolve(input.path);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
this.Log.debug('acquire error: ', e);
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find device by path
|
|
137
|
+
*/
|
|
138
|
+
async findDevice(path: string) {
|
|
139
|
+
// If device list is empty, refresh it first
|
|
140
|
+
if (this.deviceList.length === 0) {
|
|
141
|
+
await this.getConnectedDevices();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let device = this.deviceList.find(d => d.path === path);
|
|
145
|
+
|
|
146
|
+
// If device not found after first attempt, try refreshing the list once more
|
|
147
|
+
if (device == null) {
|
|
148
|
+
await this.getConnectedDevices();
|
|
149
|
+
device = this.deviceList.find(d => d.path === path);
|
|
150
|
+
|
|
151
|
+
if (device == null) {
|
|
152
|
+
throw new Error('Action was interrupted.');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return device.device;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Connect to device with retry mechanism
|
|
161
|
+
*/
|
|
162
|
+
async connect(path: string, first: boolean) {
|
|
163
|
+
const maxRetries = 5;
|
|
164
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
165
|
+
try {
|
|
166
|
+
return await this.connectToDevice(path, first);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (i === maxRetries - 1) {
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
await wait(i * 200);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Connect to specific device
|
|
178
|
+
*/
|
|
179
|
+
async connectToDevice(path: string, first: boolean) {
|
|
180
|
+
const device: USBDevice = await this.findDevice(path);
|
|
181
|
+
await device.open();
|
|
182
|
+
|
|
183
|
+
if (first) {
|
|
184
|
+
await device.selectConfiguration(this.configurationId);
|
|
185
|
+
try {
|
|
186
|
+
await device.reset();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// Ignore reset errors
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await device.claimInterface(this.interfaceId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async post(session: string, name: string, data: Record<string, unknown>) {
|
|
196
|
+
await this.call(session, name, data);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Call device method
|
|
201
|
+
*/
|
|
202
|
+
async call(path: string, name: string, data: Record<string, unknown>) {
|
|
203
|
+
if (this.messages == null) {
|
|
204
|
+
throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const device = await this.findDevice(path);
|
|
208
|
+
if (!device) {
|
|
209
|
+
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { messages } = this;
|
|
213
|
+
if (LogBlockCommand.has(name)) {
|
|
214
|
+
this.Log.debug('call-', ' name: ', name);
|
|
215
|
+
} else {
|
|
216
|
+
this.Log.debug('call-', ' name: ', name, ' data: ', data);
|
|
217
|
+
}
|
|
218
|
+
const encodeBuffers = buildEncodeBuffers(messages, name, data);
|
|
219
|
+
|
|
220
|
+
for (const buffer of encodeBuffers) {
|
|
221
|
+
const newArray: Uint8Array = new Uint8Array(PACKET_SIZE);
|
|
222
|
+
newArray[0] = 63;
|
|
223
|
+
newArray.set(new Uint8Array(buffer), 1);
|
|
224
|
+
// console.log('send packet: ', newArray);
|
|
225
|
+
|
|
226
|
+
if (!device.opened) {
|
|
227
|
+
await this.connect(path, false);
|
|
228
|
+
}
|
|
229
|
+
await device.transferOut(this.endpointId, newArray);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const resData = await this.receiveData(path);
|
|
233
|
+
if (typeof resData !== 'string') {
|
|
234
|
+
throw ERRORS.TypedError(HardwareErrorCode.NetworkError, 'Returning data is not string.');
|
|
235
|
+
}
|
|
236
|
+
const jsonData = receiveOne(messages, resData);
|
|
237
|
+
return check.call(jsonData);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Receive data from device
|
|
242
|
+
*/
|
|
243
|
+
async receiveData(path: string) {
|
|
244
|
+
const device: USBDevice = await this.findDevice(path);
|
|
245
|
+
if (!device.opened) {
|
|
246
|
+
await this.connect(path, false);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const firstPacket = await device.transferIn(this.endpointId, PACKET_SIZE);
|
|
250
|
+
const firstData = firstPacket.data?.buffer.slice(1);
|
|
251
|
+
console.log('receive first packet: ', firstPacket);
|
|
252
|
+
const { length, typeId, restBuffer } = decodeProtocol.decodeChunked(firstData as ArrayBuffer);
|
|
253
|
+
|
|
254
|
+
console.log('chunk length: ', length);
|
|
255
|
+
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
|
257
|
+
const lengthWithHeader = Number(length + HEADER_LENGTH);
|
|
258
|
+
const decoded = new ByteBuffer(lengthWithHeader);
|
|
259
|
+
decoded.writeUint16(typeId);
|
|
260
|
+
decoded.writeUint32(length);
|
|
261
|
+
if (length) {
|
|
262
|
+
decoded.append(restBuffer);
|
|
263
|
+
}
|
|
264
|
+
console.log('first decoded: ', decoded);
|
|
265
|
+
|
|
266
|
+
while (decoded.offset < lengthWithHeader) {
|
|
267
|
+
const res = await device.transferIn(this.endpointId, PACKET_SIZE);
|
|
268
|
+
|
|
269
|
+
if (!res.data) {
|
|
270
|
+
throw new Error('no data');
|
|
271
|
+
}
|
|
272
|
+
if (res.data.byteLength === 0) {
|
|
273
|
+
// empty data
|
|
274
|
+
console.warn('empty data');
|
|
275
|
+
}
|
|
276
|
+
const buffer = res.data.buffer.slice(1);
|
|
277
|
+
if (lengthWithHeader - decoded.offset >= PACKET_SIZE) {
|
|
278
|
+
decoded.append(buffer as unknown as ArrayBuffer);
|
|
279
|
+
} else {
|
|
280
|
+
decoded.append(
|
|
281
|
+
buffer.slice(0, lengthWithHeader - decoded.offset) as unknown as ArrayBuffer
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
decoded.reset();
|
|
286
|
+
const result = decoded.toBuffer();
|
|
287
|
+
return Buffer.from(result as unknown as ArrayBuffer).toString('hex');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Release device
|
|
292
|
+
*/
|
|
293
|
+
async release(path: string) {
|
|
294
|
+
const device: USBDevice = await this.findDevice(path);
|
|
295
|
+
await device.releaseInterface(this.interfaceId);
|
|
296
|
+
await device.close();
|
|
297
|
+
}
|
|
298
|
+
}
|