@onekeyfe/hd-transport-electron 1.2.2-alpha.101 → 1.2.2-alpha.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{index-d51c7da6.js → index-052b4b5b.js} +33 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/{noble-ble-handler-0b455c4f.js → noble-ble-handler-eff26dd3.js} +309 -68
- package/dist/noble-ble-handler.d.ts +3 -0
- package/dist/noble-ble-handler.d.ts.map +1 -1
- package/dist/types/desktop-api.d.ts +2 -0
- package/dist/types/desktop-api.d.ts.map +1 -1
- package/dist/types/noble-extended.d.ts +2 -0
- package/dist/types/noble-extended.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/noble-ble-handler.test.ts +517 -0
- package/src/index.ts +7 -0
- package/src/noble-ble-handler.ts +352 -80
- package/src/types/desktop-api.ts +31 -0
- package/src/types/noble-extended.ts +2 -0
package/src/noble-ble-handler.ts
CHANGED
|
@@ -40,6 +40,31 @@ import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './type
|
|
|
40
40
|
// Noble will be dynamically imported to avoid bundling issues
|
|
41
41
|
let noble: NobleModule | null = null;
|
|
42
42
|
let logger: Logger | null = null;
|
|
43
|
+
let disposing = false;
|
|
44
|
+
let nativeReleased = false;
|
|
45
|
+
let disposePromise: Promise<void> | undefined;
|
|
46
|
+
let windowCleanup: Promise<void> | undefined;
|
|
47
|
+
let removeIpcHandlers: (() => void) | undefined;
|
|
48
|
+
const pendingCancellations = new Set<() => void>();
|
|
49
|
+
const nativeConnections = new Set<{ cancel: () => void; settled: Promise<void> }>();
|
|
50
|
+
|
|
51
|
+
function trackNativeConnection(operation: Promise<unknown>, cancel: () => void): void {
|
|
52
|
+
const connection = {
|
|
53
|
+
cancel,
|
|
54
|
+
settled: operation.then(
|
|
55
|
+
() => undefined,
|
|
56
|
+
() => undefined
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
nativeConnections.add(connection);
|
|
60
|
+
connection.settled = connection.settled.finally(() => nativeConnections.delete(connection));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertBleActive(): void {
|
|
64
|
+
if (disposing) {
|
|
65
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
43
68
|
|
|
44
69
|
type NobleBleNativeError = Error & {
|
|
45
70
|
nativeErrorCode?: number;
|
|
@@ -49,7 +74,8 @@ type NobleBleNativeError = Error & {
|
|
|
49
74
|
export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
|
|
50
75
|
const errorMessage = error.message;
|
|
51
76
|
const isInvalidMacOsBond =
|
|
52
|
-
error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain'
|
|
77
|
+
(error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
|
|
78
|
+
(error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
|
|
53
79
|
if (isInvalidMacOsBond) {
|
|
54
80
|
const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
|
|
55
81
|
return ERRORS.TypedError(
|
|
@@ -116,6 +142,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
|
|
|
116
142
|
// Device cache and connection state
|
|
117
143
|
const discoveredDevices = new Map<string, Peripheral>();
|
|
118
144
|
const connectedDevices = new Map<string, Peripheral>();
|
|
145
|
+
type PendingDeviceConnection = {
|
|
146
|
+
peripheral: Peripheral;
|
|
147
|
+
generation: number;
|
|
148
|
+
cancel: (error: unknown) => void;
|
|
149
|
+
};
|
|
150
|
+
const connectingDevices = new Map<string, PendingDeviceConnection>();
|
|
151
|
+
let nextConnectionGeneration = 0;
|
|
152
|
+
|
|
153
|
+
function connectPeripheralWithCancellation(
|
|
154
|
+
peripheral: Peripheral,
|
|
155
|
+
deviceId: string,
|
|
156
|
+
messagePrefix = ''
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
assertBleActive();
|
|
159
|
+
return new Promise<void>((resolve, reject) => {
|
|
160
|
+
const generation = ++nextConnectionGeneration;
|
|
161
|
+
let settled = false;
|
|
162
|
+
const settle = (settler: () => void): boolean => {
|
|
163
|
+
if (settled) return false;
|
|
164
|
+
settled = true;
|
|
165
|
+
if (connectingDevices.get(deviceId)?.generation === generation) {
|
|
166
|
+
connectingDevices.delete(deviceId);
|
|
167
|
+
}
|
|
168
|
+
settler();
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
const pendingConnection: PendingDeviceConnection = {
|
|
172
|
+
peripheral,
|
|
173
|
+
generation,
|
|
174
|
+
cancel: error => {
|
|
175
|
+
settle(() => reject(error));
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
connectingDevices
|
|
179
|
+
.get(deviceId)
|
|
180
|
+
?.cancel(
|
|
181
|
+
ERRORS.TypedError(
|
|
182
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
183
|
+
`Device ${deviceId} connect attempt was superseded`
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
connectingDevices.set(deviceId, pendingConnection);
|
|
187
|
+
|
|
188
|
+
let completeNative!: () => void;
|
|
189
|
+
trackNativeConnection(
|
|
190
|
+
new Promise<void>(resolve => {
|
|
191
|
+
completeNative = resolve;
|
|
192
|
+
}),
|
|
193
|
+
() => {
|
|
194
|
+
if (typeof peripheral.cancelConnect === 'function') peripheral.cancelConnect();
|
|
195
|
+
else noble?.cancelConnect?.(deviceId);
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
const onConnect = (error?: Error | null) => {
|
|
199
|
+
if (nativeReleased) {
|
|
200
|
+
completeNative();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (error) {
|
|
204
|
+
settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
|
|
205
|
+
completeNative();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
!settle(() => {
|
|
211
|
+
connectedDevices.set(deviceId, peripheral);
|
|
212
|
+
resolve();
|
|
213
|
+
})
|
|
214
|
+
) {
|
|
215
|
+
const activeConnection = connectingDevices.get(deviceId);
|
|
216
|
+
if (!activeConnection || activeConnection.peripheral !== peripheral) {
|
|
217
|
+
try {
|
|
218
|
+
peripheral.removeAllListeners('disconnect');
|
|
219
|
+
runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
220
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
221
|
+
timeoutBehavior: 'resolve',
|
|
222
|
+
})
|
|
223
|
+
.catch(() => undefined)
|
|
224
|
+
.finally(completeNative);
|
|
225
|
+
return;
|
|
226
|
+
} catch {
|
|
227
|
+
// Best-effort cleanup for a stale native callback.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
completeNative();
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
peripheral.connect(onConnect);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
completeNative();
|
|
237
|
+
settle(() => reject(error));
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
119
241
|
const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
|
|
120
242
|
const deviceCharacteristics = new Map<string, CharacteristicPair>();
|
|
121
243
|
const notificationCallbacks = new Map<string, (data: string) => void>();
|
|
@@ -320,6 +442,7 @@ function updateBluetoothState(state: string): void {
|
|
|
320
442
|
|
|
321
443
|
// Initialize Noble
|
|
322
444
|
async function initializeNoble(): Promise<void> {
|
|
445
|
+
assertBleActive();
|
|
323
446
|
if (noble) return;
|
|
324
447
|
|
|
325
448
|
try {
|
|
@@ -345,12 +468,14 @@ async function initializeNoble(): Promise<void> {
|
|
|
345
468
|
}
|
|
346
469
|
|
|
347
470
|
const timeout = setTimeout(() => {
|
|
471
|
+
cleanup();
|
|
348
472
|
reject(
|
|
349
473
|
ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
|
|
350
474
|
);
|
|
351
475
|
}, BLUETOOTH_INIT_TIMEOUT);
|
|
352
476
|
|
|
353
477
|
const cleanup = () => {
|
|
478
|
+
pendingCancellations.delete(cancel);
|
|
354
479
|
clearTimeout(timeout);
|
|
355
480
|
if (noble) {
|
|
356
481
|
noble.removeListener('stateChange', onStateChange);
|
|
@@ -375,9 +500,17 @@ async function initializeNoble(): Promise<void> {
|
|
|
375
500
|
}
|
|
376
501
|
};
|
|
377
502
|
|
|
503
|
+
const cancel = () => {
|
|
504
|
+
cleanup();
|
|
505
|
+
reject(
|
|
506
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
507
|
+
);
|
|
508
|
+
};
|
|
509
|
+
pendingCancellations.add(cancel);
|
|
378
510
|
noble.on('stateChange', onStateChange);
|
|
379
511
|
});
|
|
380
512
|
|
|
513
|
+
assertBleActive();
|
|
381
514
|
// Set up device discovery
|
|
382
515
|
if (!persistentDiscoverListener) {
|
|
383
516
|
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
@@ -462,6 +595,7 @@ function armIdleDisconnect(
|
|
|
462
595
|
reason: 'idle' | 'busy-backstop' = 'idle'
|
|
463
596
|
): void {
|
|
464
597
|
clearIdleDisconnect(deviceId);
|
|
598
|
+
if (disposing) return;
|
|
465
599
|
idleDisconnectTimers.set(
|
|
466
600
|
deviceId,
|
|
467
601
|
setTimeout(() => {
|
|
@@ -544,6 +678,14 @@ function cleanupDevice(
|
|
|
544
678
|
|
|
545
679
|
// 1. Clean up connection state
|
|
546
680
|
if (cleanupConnection) {
|
|
681
|
+
connectingDevices
|
|
682
|
+
.get(deviceId)
|
|
683
|
+
?.cancel(
|
|
684
|
+
ERRORS.TypedError(
|
|
685
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
686
|
+
`Device ${deviceId} disconnected while connecting`
|
|
687
|
+
)
|
|
688
|
+
);
|
|
547
689
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
548
690
|
if (disconnectEntry) {
|
|
549
691
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -653,6 +795,7 @@ async function writeCharacteristicWithoutResponse(
|
|
|
653
795
|
writeCharacteristic: Characteristic,
|
|
654
796
|
buffer: Buffer
|
|
655
797
|
): Promise<void> {
|
|
798
|
+
assertBleActive();
|
|
656
799
|
return new Promise((resolve, reject) => {
|
|
657
800
|
writeCharacteristic.write(buffer, true, (error?: Error) => {
|
|
658
801
|
if (error) {
|
|
@@ -937,6 +1080,7 @@ async function performTargetedScan(
|
|
|
937
1080
|
targetDeviceId: string,
|
|
938
1081
|
timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
|
|
939
1082
|
): Promise<Peripheral | null> {
|
|
1083
|
+
assertBleActive();
|
|
940
1084
|
if (!noble) {
|
|
941
1085
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
942
1086
|
}
|
|
@@ -952,6 +1096,7 @@ async function performTargetedScan(
|
|
|
952
1096
|
const finish = async (peripheral: Peripheral | null, error?: Error) => {
|
|
953
1097
|
if (settled) return;
|
|
954
1098
|
settled = true;
|
|
1099
|
+
pendingCancellations.delete(cancel);
|
|
955
1100
|
if (timeoutId) clearTimeout(timeoutId);
|
|
956
1101
|
nobleInstance.removeListener('discover', onDiscover);
|
|
957
1102
|
await waitForNobleScanStop(nobleInstance);
|
|
@@ -961,6 +1106,7 @@ async function performTargetedScan(
|
|
|
961
1106
|
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
962
1107
|
return;
|
|
963
1108
|
}
|
|
1109
|
+
assertBleActive();
|
|
964
1110
|
if (peripheral) {
|
|
965
1111
|
discoveredDevices.set(peripheral.id, peripheral);
|
|
966
1112
|
}
|
|
@@ -983,6 +1129,11 @@ async function performTargetedScan(
|
|
|
983
1129
|
finish(null).catch(reject);
|
|
984
1130
|
}, timeoutMs);
|
|
985
1131
|
|
|
1132
|
+
const cancel = () => {
|
|
1133
|
+
finish(null, new Error('Noble BLE is shutting down')).catch(reject);
|
|
1134
|
+
};
|
|
1135
|
+
pendingCancellations.add(cancel);
|
|
1136
|
+
|
|
986
1137
|
// Add local listener for this scan
|
|
987
1138
|
nobleInstance.on('discover', onDiscover);
|
|
988
1139
|
|
|
@@ -1008,6 +1159,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1008
1159
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
1009
1160
|
}
|
|
1010
1161
|
|
|
1162
|
+
assertBleActive();
|
|
1011
1163
|
// Capture noble reference for use in closures (TypeScript narrowing)
|
|
1012
1164
|
const nobleInstance = noble;
|
|
1013
1165
|
|
|
@@ -1022,12 +1174,15 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1022
1174
|
|
|
1023
1175
|
return new Promise((resolve, reject) => {
|
|
1024
1176
|
const devices: DeviceInfo[] = [];
|
|
1177
|
+
let settled = false;
|
|
1025
1178
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
1026
1179
|
|
|
1027
1180
|
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
1028
1181
|
// has stopped. Resolving enumerate before this callback creates a race with
|
|
1029
1182
|
// an immediately-following connection attempt.
|
|
1030
1183
|
const cleanup = async () => {
|
|
1184
|
+
settled = true;
|
|
1185
|
+
pendingCancellations.delete(cancel);
|
|
1031
1186
|
clearTimeout(timeoutId);
|
|
1032
1187
|
if (intervalId) clearInterval(intervalId);
|
|
1033
1188
|
await waitForNobleScanStop(nobleInstance);
|
|
@@ -1051,6 +1206,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1051
1206
|
|
|
1052
1207
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
1053
1208
|
const timeoutId = setTimeout(async () => {
|
|
1209
|
+
if (settled) return;
|
|
1054
1210
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
1055
1211
|
checkDevices();
|
|
1056
1212
|
await cleanup();
|
|
@@ -1058,11 +1214,17 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1058
1214
|
resolve(devices);
|
|
1059
1215
|
}, DEVICE_SCAN_TIMEOUT);
|
|
1060
1216
|
|
|
1217
|
+
const cancel = () => {
|
|
1218
|
+
cleanup().then(() => resolve([]), reject);
|
|
1219
|
+
};
|
|
1220
|
+
pendingCancellations.add(cancel);
|
|
1221
|
+
|
|
1061
1222
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
1062
1223
|
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
1063
1224
|
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
1064
1225
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
1065
1226
|
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
1227
|
+
if (settled) return;
|
|
1066
1228
|
if (error) {
|
|
1067
1229
|
await cleanup();
|
|
1068
1230
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
@@ -1080,7 +1242,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1080
1242
|
|
|
1081
1243
|
// Stop scanning
|
|
1082
1244
|
async function stopScanning(): Promise<void> {
|
|
1083
|
-
if (!noble) return;
|
|
1245
|
+
if (!noble || nativeReleased) return;
|
|
1084
1246
|
const nobleInstance = noble;
|
|
1085
1247
|
await waitForNobleScanStop(nobleInstance);
|
|
1086
1248
|
logger?.info('[NobleBLE] Scanning stopped');
|
|
@@ -1153,13 +1315,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
1153
1315
|
async function discoverServicesAndCharacteristics(
|
|
1154
1316
|
peripheral: Peripheral
|
|
1155
1317
|
): Promise<CharacteristicPair> {
|
|
1318
|
+
assertBleActive();
|
|
1156
1319
|
// Cleanup resources - will be set up and cleaned in try/finally
|
|
1157
1320
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1158
1321
|
let onDisconnect: (() => void) | undefined;
|
|
1159
1322
|
|
|
1160
1323
|
const cleanup = () => {
|
|
1161
1324
|
if (timeoutId) clearTimeout(timeoutId);
|
|
1162
|
-
if (onDisconnect)
|
|
1325
|
+
if (onDisconnect) {
|
|
1326
|
+
peripheral.removeListener('disconnect', onDisconnect);
|
|
1327
|
+
pendingCancellations.delete(onDisconnect);
|
|
1328
|
+
}
|
|
1163
1329
|
};
|
|
1164
1330
|
|
|
1165
1331
|
// Racing promises for timeout and disconnect
|
|
@@ -1180,6 +1346,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1180
1346
|
)
|
|
1181
1347
|
);
|
|
1182
1348
|
};
|
|
1349
|
+
pendingCancellations.add(onDisconnect);
|
|
1183
1350
|
peripheral.once('disconnect', onDisconnect);
|
|
1184
1351
|
});
|
|
1185
1352
|
|
|
@@ -1205,6 +1372,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1205
1372
|
});
|
|
1206
1373
|
});
|
|
1207
1374
|
|
|
1375
|
+
assertBleActive();
|
|
1208
1376
|
if (!services || services.length === 0) {
|
|
1209
1377
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
|
|
1210
1378
|
}
|
|
@@ -1311,17 +1479,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1311
1479
|
}
|
|
1312
1480
|
|
|
1313
1481
|
// Step 3: Re-establish connection
|
|
1314
|
-
await
|
|
1315
|
-
peripheral.connect(error => {
|
|
1316
|
-
if (error) {
|
|
1317
|
-
reject(createNobleBleConnectionError(error));
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
resolve();
|
|
1321
|
-
});
|
|
1322
|
-
});
|
|
1482
|
+
await connectPeripheralWithCancellation(peripheral, deviceId);
|
|
1323
1483
|
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1324
|
-
connectedDevices.set(deviceId, peripheral);
|
|
1325
1484
|
|
|
1326
1485
|
// Wait for connection to stabilize
|
|
1327
1486
|
await wait(500);
|
|
@@ -1357,16 +1516,11 @@ async function freshScanAndDiscover(
|
|
|
1357
1516
|
discoveredDevices.set(deviceId, freshPeripheral);
|
|
1358
1517
|
|
|
1359
1518
|
// Connect to fresh peripheral
|
|
1360
|
-
await
|
|
1361
|
-
freshPeripheral
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
connectedDevices.set(deviceId, freshPeripheral);
|
|
1366
|
-
resolve();
|
|
1367
|
-
}
|
|
1368
|
-
});
|
|
1369
|
-
});
|
|
1519
|
+
await connectPeripheralWithCancellation(
|
|
1520
|
+
freshPeripheral,
|
|
1521
|
+
deviceId,
|
|
1522
|
+
'Fresh peripheral connection failed: '
|
|
1523
|
+
);
|
|
1370
1524
|
|
|
1371
1525
|
// Setup disconnect listener for fresh peripheral
|
|
1372
1526
|
setupDisconnectListener(freshPeripheral, deviceId, webContents);
|
|
@@ -1438,6 +1592,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1438
1592
|
minTimeout: 500,
|
|
1439
1593
|
maxTimeout: 3000,
|
|
1440
1594
|
onFailedAttempt: error => {
|
|
1595
|
+
assertBleActive();
|
|
1441
1596
|
// This runs after each failed attempt
|
|
1442
1597
|
logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
|
|
1443
1598
|
message: error.message,
|
|
@@ -1473,13 +1628,17 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1473
1628
|
try {
|
|
1474
1629
|
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1475
1630
|
} catch (resetError) {
|
|
1476
|
-
if (
|
|
1631
|
+
if (
|
|
1632
|
+
isBleStaleBondHardwareError(resetError) ||
|
|
1633
|
+
(resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
|
|
1634
|
+
) {
|
|
1477
1635
|
throw resetError;
|
|
1478
1636
|
}
|
|
1479
1637
|
// A failed reset must not abort the attempt: discovery on the existing
|
|
1480
1638
|
// link, then the fresh-scan fallback, still have a chance to recover.
|
|
1481
1639
|
logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
|
|
1482
1640
|
}
|
|
1641
|
+
assertBleActive();
|
|
1483
1642
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1484
1643
|
|
|
1485
1644
|
try {
|
|
@@ -1522,6 +1681,7 @@ const directConnectCooldownUntil = new Map<string, number>();
|
|
|
1522
1681
|
* Ported from the Trezor connector, where it is field-proven.
|
|
1523
1682
|
*/
|
|
1524
1683
|
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1684
|
+
assertBleActive();
|
|
1525
1685
|
const nobleInstance = noble as
|
|
1526
1686
|
| (typeof noble & {
|
|
1527
1687
|
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
@@ -1532,34 +1692,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1532
1692
|
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1533
1693
|
if (Date.now() < cooldownUntil) return undefined;
|
|
1534
1694
|
|
|
1695
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1696
|
+
let cancel: (() => void) | undefined;
|
|
1697
|
+
let abandoned = false;
|
|
1535
1698
|
try {
|
|
1536
1699
|
// The late-orphan guard must attach to THIS pending connect.
|
|
1537
1700
|
const directPromise = nobleInstance.connectAsync(deviceId);
|
|
1701
|
+
trackNativeConnection(
|
|
1702
|
+
directPromise.then(async late => {
|
|
1703
|
+
if ((!abandoned && !disposing) || nativeReleased) return;
|
|
1704
|
+
const peripheral = late ?? discoveredDevices.get(deviceId);
|
|
1705
|
+
if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
|
|
1706
|
+
peripheral.removeAllListeners('disconnect');
|
|
1707
|
+
await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
1708
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
1709
|
+
timeoutBehavior: 'resolve',
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}),
|
|
1713
|
+
() => nobleInstance.cancelConnect?.(deviceId)
|
|
1714
|
+
);
|
|
1538
1715
|
const raced = await Promise.race([
|
|
1539
1716
|
directPromise,
|
|
1540
1717
|
new Promise<'timeout'>(resolve => {
|
|
1541
|
-
|
|
1718
|
+
cancel = () => {
|
|
1719
|
+
abandoned = true;
|
|
1720
|
+
resolve('timeout');
|
|
1721
|
+
};
|
|
1722
|
+
pendingCancellations.add(cancel);
|
|
1723
|
+
timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
|
|
1542
1724
|
}),
|
|
1543
1725
|
]);
|
|
1544
|
-
if (raced === 'timeout') {
|
|
1726
|
+
if (raced === 'timeout' || disposing) {
|
|
1545
1727
|
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1546
1728
|
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1547
1729
|
deviceId,
|
|
1548
1730
|
});
|
|
1549
|
-
// Promise.race times out the caller only; a late success orphans the link.
|
|
1550
|
-
directPromise
|
|
1551
|
-
.then(late => {
|
|
1552
|
-
const latePeripheral = late ?? discoveredDevices.get(deviceId);
|
|
1553
|
-
if (
|
|
1554
|
-
latePeripheral &&
|
|
1555
|
-
latePeripheral.state === 'connected' &&
|
|
1556
|
-
!connectedDevices.has(deviceId)
|
|
1557
|
-
) {
|
|
1558
|
-
latePeripheral.removeAllListeners('disconnect');
|
|
1559
|
-
latePeripheral.disconnect(() => undefined);
|
|
1560
|
-
}
|
|
1561
|
-
})
|
|
1562
|
-
.catch(() => undefined);
|
|
1563
1731
|
return undefined;
|
|
1564
1732
|
}
|
|
1565
1733
|
// Backends emit `discover` as a side effect, so the cache may hold it.
|
|
@@ -1574,6 +1742,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1574
1742
|
error: String(error),
|
|
1575
1743
|
});
|
|
1576
1744
|
return undefined;
|
|
1745
|
+
} finally {
|
|
1746
|
+
clearTimeout(timer);
|
|
1747
|
+
if (cancel) pendingCancellations.delete(cancel);
|
|
1577
1748
|
}
|
|
1578
1749
|
}
|
|
1579
1750
|
|
|
@@ -1644,6 +1815,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1644
1815
|
}
|
|
1645
1816
|
}
|
|
1646
1817
|
|
|
1818
|
+
assertBleActive();
|
|
1647
1819
|
// At this point, peripheral is guaranteed to be defined
|
|
1648
1820
|
if (!peripheral) {
|
|
1649
1821
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
|
|
@@ -1666,6 +1838,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1666
1838
|
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1667
1839
|
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1668
1840
|
// renderer, and the reuse fast path below returns before any other setup.
|
|
1841
|
+
assertBleActive();
|
|
1669
1842
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1670
1843
|
|
|
1671
1844
|
// Check if we already have characteristics for this device
|
|
@@ -1716,6 +1889,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1716
1889
|
deviceId,
|
|
1717
1890
|
webContents
|
|
1718
1891
|
);
|
|
1892
|
+
assertBleActive();
|
|
1719
1893
|
deviceCharacteristics.set(deviceId, characteristics);
|
|
1720
1894
|
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1721
1895
|
} catch (setupError) {
|
|
@@ -1727,51 +1901,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1727
1901
|
return;
|
|
1728
1902
|
}
|
|
1729
1903
|
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
connectedPeripheral.connect(async (error: Error | undefined) => {
|
|
1734
|
-
if (error) {
|
|
1735
|
-
logger?.error('[NobleBLE] Connection failed:', error);
|
|
1736
|
-
reject(createNobleBleConnectionError(error));
|
|
1737
|
-
return;
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1741
|
-
connectedDevices.set(deviceId, connectedPeripheral);
|
|
1904
|
+
const connectedPeripheral = peripheral;
|
|
1905
|
+
await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
|
|
1906
|
+
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1742
1907
|
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
// pending forever. disconnectDevice always settles (it no-ops on an
|
|
1759
|
-
// unknown peripheral and caps the confirm wait).
|
|
1760
|
-
disconnectDevice(deviceId)
|
|
1761
|
-
.catch(() => undefined)
|
|
1762
|
-
.then(() => reject(setupError));
|
|
1763
|
-
}
|
|
1764
|
-
});
|
|
1765
|
-
});
|
|
1908
|
+
// Setup connection and discover services
|
|
1909
|
+
try {
|
|
1910
|
+
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1911
|
+
connectedPeripheral,
|
|
1912
|
+
deviceId,
|
|
1913
|
+
webContents
|
|
1914
|
+
);
|
|
1915
|
+
assertBleActive();
|
|
1916
|
+
deviceCharacteristics.set(deviceId, characteristics);
|
|
1917
|
+
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1918
|
+
} catch (setupError) {
|
|
1919
|
+
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1920
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1921
|
+
throw setupError;
|
|
1922
|
+
}
|
|
1766
1923
|
}
|
|
1767
1924
|
|
|
1768
1925
|
// Disconnect device
|
|
1769
1926
|
async function disconnectDevice(deviceId: string): Promise<void> {
|
|
1770
|
-
|
|
1927
|
+
if (nativeReleased) return;
|
|
1928
|
+
const pendingConnection = connectingDevices.get(deviceId);
|
|
1929
|
+
const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
|
|
1771
1930
|
if (!peripheral) {
|
|
1772
1931
|
return;
|
|
1773
1932
|
}
|
|
1774
1933
|
|
|
1934
|
+
pendingConnection?.cancel(
|
|
1935
|
+
ERRORS.TypedError(
|
|
1936
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
1937
|
+
`Device ${deviceId} connection cancelled`
|
|
1938
|
+
)
|
|
1939
|
+
);
|
|
1940
|
+
|
|
1775
1941
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1776
1942
|
if (disconnectEntry) {
|
|
1777
1943
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -1799,6 +1965,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1799
1965
|
|
|
1800
1966
|
// Unsubscribe from notifications
|
|
1801
1967
|
async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
1968
|
+
if (nativeReleased) return;
|
|
1802
1969
|
const peripheral = connectedDevices.get(deviceId);
|
|
1803
1970
|
const characteristics = deviceCharacteristics.get(deviceId);
|
|
1804
1971
|
|
|
@@ -1826,7 +1993,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1826
1993
|
subscribedDevices.delete(deviceId);
|
|
1827
1994
|
} finally {
|
|
1828
1995
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1829
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
1996
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1830
1997
|
}
|
|
1831
1998
|
}
|
|
1832
1999
|
|
|
@@ -1915,11 +2082,13 @@ async function subscribeNotifications(
|
|
|
1915
2082
|
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1916
2083
|
timeoutBehavior: 'resolve',
|
|
1917
2084
|
});
|
|
2085
|
+
assertBleActive();
|
|
1918
2086
|
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1919
2087
|
timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
1920
2088
|
timeoutBehavior: 'reject',
|
|
1921
2089
|
});
|
|
1922
2090
|
|
|
2091
|
+
assertBleActive();
|
|
1923
2092
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
1924
2093
|
// Windows BLE pairing detection: receiving any data means device is paired
|
|
1925
2094
|
if (!pairedDevices.has(deviceId)) {
|
|
@@ -1934,19 +2103,27 @@ async function subscribeNotifications(
|
|
|
1934
2103
|
const subscribeStartedAt = Date.now();
|
|
1935
2104
|
try {
|
|
1936
2105
|
await rebuildAppSubscription(deviceId, notifyCharacteristic);
|
|
2106
|
+
assertBleActive();
|
|
1937
2107
|
subscribedDevices.set(deviceId, true);
|
|
1938
2108
|
logger?.info('[NobleBLE] Notification subscription active', {
|
|
1939
2109
|
deviceId,
|
|
1940
2110
|
ms: Date.now() - subscribeStartedAt,
|
|
1941
2111
|
});
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
throw createNobleBleConnectionError(
|
|
2114
|
+
error as NobleBleNativeError,
|
|
2115
|
+
'Notification subscription failed: '
|
|
2116
|
+
);
|
|
1942
2117
|
} finally {
|
|
1943
2118
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1944
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2119
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1945
2120
|
}
|
|
1946
2121
|
}
|
|
1947
2122
|
|
|
1948
2123
|
// Setup IPC handlers
|
|
1949
2124
|
export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
2125
|
+
if (disposing) return;
|
|
2126
|
+
let windowDestroyed = false;
|
|
1950
2127
|
try {
|
|
1951
2128
|
// @ts-ignore – electron-log is only available at runtime
|
|
1952
2129
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
@@ -1956,11 +2133,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1956
2133
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1957
2134
|
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1958
2135
|
|
|
2136
|
+
const channels = new Set<string>();
|
|
2137
|
+
removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
|
|
2138
|
+
|
|
1959
2139
|
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1960
2140
|
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
2141
|
+
channels.add(channel);
|
|
1961
2142
|
ipcMain.removeHandler(channel);
|
|
1962
2143
|
ipcMain.handle(channel, async (...args) => {
|
|
1963
2144
|
try {
|
|
2145
|
+
// A replacement renderer must not race the old renderer's shared-state cleanup.
|
|
2146
|
+
if (windowCleanup) await windowCleanup;
|
|
2147
|
+
assertBleActive();
|
|
2148
|
+
if (windowDestroyed) {
|
|
2149
|
+
throw ERRORS.TypedError(
|
|
2150
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
2151
|
+
'BLE window destroyed'
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
1964
2154
|
return await Promise.resolve(listener(...args));
|
|
1965
2155
|
} catch (error) {
|
|
1966
2156
|
return createNobleBleIpcErrorResponse(error);
|
|
@@ -2116,16 +2306,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2116
2306
|
}
|
|
2117
2307
|
});
|
|
2118
2308
|
|
|
2119
|
-
//
|
|
2309
|
+
// Window cleanup also runs during renderer soft restart; native release belongs to app quit.
|
|
2120
2310
|
webContents.on('destroyed', () => {
|
|
2311
|
+
windowDestroyed = true;
|
|
2312
|
+
if (disposing) return;
|
|
2121
2313
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
2122
|
-
|
|
2314
|
+
const previousCleanup = windowCleanup;
|
|
2315
|
+
windowCleanup = (async () => {
|
|
2316
|
+
if (previousCleanup) await previousCleanup;
|
|
2317
|
+
if (disposing) return;
|
|
2123
2318
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
2124
2319
|
for (const deviceId of deviceIds) {
|
|
2320
|
+
if (disposing) return;
|
|
2125
2321
|
await unsubscribeNotifications(deviceId).catch(() => undefined);
|
|
2322
|
+
if (disposing) return;
|
|
2126
2323
|
await disconnectDevice(deviceId).catch(() => undefined);
|
|
2127
2324
|
}
|
|
2128
2325
|
|
|
2326
|
+
if (disposing) return;
|
|
2129
2327
|
await stopScanning().catch(() => undefined);
|
|
2130
2328
|
// persistentStateListener is process-lifetime, NOT per-window: this
|
|
2131
2329
|
// destroy handler also fires on a renderer soft restart, and removing
|
|
@@ -2147,3 +2345,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2147
2345
|
throw error;
|
|
2148
2346
|
}
|
|
2149
2347
|
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Terminal, bounded cleanup while the Node environment is still alive.
|
|
2351
|
+
* If Noble is shared with another transport, releaseNoble must queue the instance
|
|
2352
|
+
* and call stop() once after every transport has finished its cleanup.
|
|
2353
|
+
*/
|
|
2354
|
+
export function disposeNobleBleSupport(
|
|
2355
|
+
releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
|
|
2356
|
+
): Promise<void> {
|
|
2357
|
+
if (disposePromise) return disposePromise;
|
|
2358
|
+
disposing = true;
|
|
2359
|
+
removeIpcHandlers?.();
|
|
2360
|
+
const instance = noble;
|
|
2361
|
+
const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
|
|
2362
|
+
const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
|
|
2363
|
+
for (const cancel of pendingCancellations) cancel();
|
|
2364
|
+
for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
|
|
2365
|
+
for (const pending of connectingDevices.values()) {
|
|
2366
|
+
pending.cancel(
|
|
2367
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
const connections = Array.from(nativeConnections);
|
|
2371
|
+
for (const connection of connections) {
|
|
2372
|
+
try {
|
|
2373
|
+
connection.cancel();
|
|
2374
|
+
} catch (error) {
|
|
2375
|
+
logger?.warn('[NobleBLE] Native connect cancellation failed', error);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
2379
|
+
disposePromise = (async () => {
|
|
2380
|
+
try {
|
|
2381
|
+
await Promise.race([
|
|
2382
|
+
Promise.allSettled([
|
|
2383
|
+
windowCleanup,
|
|
2384
|
+
...connections.map(connection => connection.settled),
|
|
2385
|
+
stopScanning(),
|
|
2386
|
+
...pendingPeripherals.map(peripheral =>
|
|
2387
|
+
runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
2388
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
2389
|
+
timeoutBehavior: 'resolve',
|
|
2390
|
+
})
|
|
2391
|
+
),
|
|
2392
|
+
...Array.from(deviceIds, async id => {
|
|
2393
|
+
await unsubscribeNotifications(id).catch(() => undefined);
|
|
2394
|
+
await disconnectDevice(id).catch(() => undefined);
|
|
2395
|
+
}),
|
|
2396
|
+
]),
|
|
2397
|
+
new Promise<void>(resolve => {
|
|
2398
|
+
timeout = setTimeout(() => {
|
|
2399
|
+
logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
|
|
2400
|
+
resolve();
|
|
2401
|
+
}, 3500);
|
|
2402
|
+
}),
|
|
2403
|
+
]);
|
|
2404
|
+
} finally {
|
|
2405
|
+
clearTimeout(timeout);
|
|
2406
|
+
// Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
|
|
2407
|
+
nativeReleased = true;
|
|
2408
|
+
cleanupNobleListeners();
|
|
2409
|
+
if (instance && persistentStateListener) {
|
|
2410
|
+
instance.removeListener('stateChange', persistentStateListener);
|
|
2411
|
+
persistentStateListener = null;
|
|
2412
|
+
}
|
|
2413
|
+
for (const id of deviceIds) cleanupDevice(id);
|
|
2414
|
+
discoveredDevices.clear();
|
|
2415
|
+
directConnectCooldownUntil.clear();
|
|
2416
|
+
if (instance) releaseNoble(instance);
|
|
2417
|
+
logger?.info('[NobleBLE] Process dispose completed');
|
|
2418
|
+
}
|
|
2419
|
+
})();
|
|
2420
|
+
return disposePromise;
|
|
2421
|
+
}
|