@onekeyfe/hd-transport-electron 1.2.2-alpha.101 → 1.2.2-alpha.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{index-d51c7da6.js → index-8e6acfc1.js} +33 -1
- package/dist/index.d.ts +12 -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-3a1d131d.js} +217 -55
- 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 +1 -0
- package/dist/types/noble-extended.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/noble-ble-handler.test.ts +441 -0
- package/src/index.ts +7 -0
- package/src/noble-ble-handler.ts +259 -64
- package/src/types/desktop-api.ts +31 -0
- package/src/types/noble-extended.ts +1 -0
package/src/noble-ble-handler.ts
CHANGED
|
@@ -40,6 +40,16 @@ 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 disposePromise: Promise<void> | undefined;
|
|
45
|
+
let removeIpcHandlers: (() => void) | undefined;
|
|
46
|
+
const pendingCancellations = new Set<() => void>();
|
|
47
|
+
|
|
48
|
+
function assertBleActive(): void {
|
|
49
|
+
if (disposing) {
|
|
50
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
43
53
|
|
|
44
54
|
type NobleBleNativeError = Error & {
|
|
45
55
|
nativeErrorCode?: number;
|
|
@@ -49,7 +59,8 @@ type NobleBleNativeError = Error & {
|
|
|
49
59
|
export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
|
|
50
60
|
const errorMessage = error.message;
|
|
51
61
|
const isInvalidMacOsBond =
|
|
52
|
-
error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain'
|
|
62
|
+
(error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
|
|
63
|
+
(error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
|
|
53
64
|
if (isInvalidMacOsBond) {
|
|
54
65
|
const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
|
|
55
66
|
return ERRORS.TypedError(
|
|
@@ -116,6 +127,74 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
|
|
|
116
127
|
// Device cache and connection state
|
|
117
128
|
const discoveredDevices = new Map<string, Peripheral>();
|
|
118
129
|
const connectedDevices = new Map<string, Peripheral>();
|
|
130
|
+
type PendingDeviceConnection = {
|
|
131
|
+
peripheral: Peripheral;
|
|
132
|
+
generation: number;
|
|
133
|
+
cancel: (error: unknown) => void;
|
|
134
|
+
};
|
|
135
|
+
const connectingDevices = new Map<string, PendingDeviceConnection>();
|
|
136
|
+
let nextConnectionGeneration = 0;
|
|
137
|
+
|
|
138
|
+
function connectPeripheralWithCancellation(
|
|
139
|
+
peripheral: Peripheral,
|
|
140
|
+
deviceId: string,
|
|
141
|
+
messagePrefix = ''
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
assertBleActive();
|
|
144
|
+
return new Promise<void>((resolve, reject) => {
|
|
145
|
+
const generation = ++nextConnectionGeneration;
|
|
146
|
+
let settled = false;
|
|
147
|
+
const settle = (settler: () => void): boolean => {
|
|
148
|
+
if (settled) return false;
|
|
149
|
+
settled = true;
|
|
150
|
+
if (connectingDevices.get(deviceId)?.generation === generation) {
|
|
151
|
+
connectingDevices.delete(deviceId);
|
|
152
|
+
}
|
|
153
|
+
settler();
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
const pendingConnection: PendingDeviceConnection = {
|
|
157
|
+
peripheral,
|
|
158
|
+
generation,
|
|
159
|
+
cancel: error => {
|
|
160
|
+
settle(() => reject(error));
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
connectingDevices
|
|
164
|
+
.get(deviceId)
|
|
165
|
+
?.cancel(
|
|
166
|
+
ERRORS.TypedError(
|
|
167
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
168
|
+
`Device ${deviceId} connect attempt was superseded`
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
connectingDevices.set(deviceId, pendingConnection);
|
|
172
|
+
|
|
173
|
+
peripheral.connect(error => {
|
|
174
|
+
if (error) {
|
|
175
|
+
settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
!settle(() => {
|
|
181
|
+
connectedDevices.set(deviceId, peripheral);
|
|
182
|
+
resolve();
|
|
183
|
+
})
|
|
184
|
+
) {
|
|
185
|
+
const activeConnection = connectingDevices.get(deviceId);
|
|
186
|
+
if (!activeConnection || activeConnection.peripheral !== peripheral) {
|
|
187
|
+
try {
|
|
188
|
+
peripheral.removeAllListeners('disconnect');
|
|
189
|
+
peripheral.disconnect(() => undefined);
|
|
190
|
+
} catch {
|
|
191
|
+
// Best-effort cleanup for a stale native callback.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
119
198
|
const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
|
|
120
199
|
const deviceCharacteristics = new Map<string, CharacteristicPair>();
|
|
121
200
|
const notificationCallbacks = new Map<string, (data: string) => void>();
|
|
@@ -320,6 +399,7 @@ function updateBluetoothState(state: string): void {
|
|
|
320
399
|
|
|
321
400
|
// Initialize Noble
|
|
322
401
|
async function initializeNoble(): Promise<void> {
|
|
402
|
+
assertBleActive();
|
|
323
403
|
if (noble) return;
|
|
324
404
|
|
|
325
405
|
try {
|
|
@@ -345,12 +425,14 @@ async function initializeNoble(): Promise<void> {
|
|
|
345
425
|
}
|
|
346
426
|
|
|
347
427
|
const timeout = setTimeout(() => {
|
|
428
|
+
cleanup();
|
|
348
429
|
reject(
|
|
349
430
|
ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
|
|
350
431
|
);
|
|
351
432
|
}, BLUETOOTH_INIT_TIMEOUT);
|
|
352
433
|
|
|
353
434
|
const cleanup = () => {
|
|
435
|
+
pendingCancellations.delete(cancel);
|
|
354
436
|
clearTimeout(timeout);
|
|
355
437
|
if (noble) {
|
|
356
438
|
noble.removeListener('stateChange', onStateChange);
|
|
@@ -375,9 +457,17 @@ async function initializeNoble(): Promise<void> {
|
|
|
375
457
|
}
|
|
376
458
|
};
|
|
377
459
|
|
|
460
|
+
const cancel = () => {
|
|
461
|
+
cleanup();
|
|
462
|
+
reject(
|
|
463
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
464
|
+
);
|
|
465
|
+
};
|
|
466
|
+
pendingCancellations.add(cancel);
|
|
378
467
|
noble.on('stateChange', onStateChange);
|
|
379
468
|
});
|
|
380
469
|
|
|
470
|
+
assertBleActive();
|
|
381
471
|
// Set up device discovery
|
|
382
472
|
if (!persistentDiscoverListener) {
|
|
383
473
|
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
@@ -462,6 +552,7 @@ function armIdleDisconnect(
|
|
|
462
552
|
reason: 'idle' | 'busy-backstop' = 'idle'
|
|
463
553
|
): void {
|
|
464
554
|
clearIdleDisconnect(deviceId);
|
|
555
|
+
if (disposing) return;
|
|
465
556
|
idleDisconnectTimers.set(
|
|
466
557
|
deviceId,
|
|
467
558
|
setTimeout(() => {
|
|
@@ -544,6 +635,14 @@ function cleanupDevice(
|
|
|
544
635
|
|
|
545
636
|
// 1. Clean up connection state
|
|
546
637
|
if (cleanupConnection) {
|
|
638
|
+
connectingDevices
|
|
639
|
+
.get(deviceId)
|
|
640
|
+
?.cancel(
|
|
641
|
+
ERRORS.TypedError(
|
|
642
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
643
|
+
`Device ${deviceId} disconnected while connecting`
|
|
644
|
+
)
|
|
645
|
+
);
|
|
547
646
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
548
647
|
if (disconnectEntry) {
|
|
549
648
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -653,6 +752,7 @@ async function writeCharacteristicWithoutResponse(
|
|
|
653
752
|
writeCharacteristic: Characteristic,
|
|
654
753
|
buffer: Buffer
|
|
655
754
|
): Promise<void> {
|
|
755
|
+
assertBleActive();
|
|
656
756
|
return new Promise((resolve, reject) => {
|
|
657
757
|
writeCharacteristic.write(buffer, true, (error?: Error) => {
|
|
658
758
|
if (error) {
|
|
@@ -937,6 +1037,7 @@ async function performTargetedScan(
|
|
|
937
1037
|
targetDeviceId: string,
|
|
938
1038
|
timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
|
|
939
1039
|
): Promise<Peripheral | null> {
|
|
1040
|
+
assertBleActive();
|
|
940
1041
|
if (!noble) {
|
|
941
1042
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
942
1043
|
}
|
|
@@ -952,6 +1053,7 @@ async function performTargetedScan(
|
|
|
952
1053
|
const finish = async (peripheral: Peripheral | null, error?: Error) => {
|
|
953
1054
|
if (settled) return;
|
|
954
1055
|
settled = true;
|
|
1056
|
+
pendingCancellations.delete(cancel);
|
|
955
1057
|
if (timeoutId) clearTimeout(timeoutId);
|
|
956
1058
|
nobleInstance.removeListener('discover', onDiscover);
|
|
957
1059
|
await waitForNobleScanStop(nobleInstance);
|
|
@@ -961,6 +1063,7 @@ async function performTargetedScan(
|
|
|
961
1063
|
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
962
1064
|
return;
|
|
963
1065
|
}
|
|
1066
|
+
assertBleActive();
|
|
964
1067
|
if (peripheral) {
|
|
965
1068
|
discoveredDevices.set(peripheral.id, peripheral);
|
|
966
1069
|
}
|
|
@@ -983,6 +1086,11 @@ async function performTargetedScan(
|
|
|
983
1086
|
finish(null).catch(reject);
|
|
984
1087
|
}, timeoutMs);
|
|
985
1088
|
|
|
1089
|
+
const cancel = () => {
|
|
1090
|
+
finish(null, new Error('Noble BLE is shutting down')).catch(reject);
|
|
1091
|
+
};
|
|
1092
|
+
pendingCancellations.add(cancel);
|
|
1093
|
+
|
|
986
1094
|
// Add local listener for this scan
|
|
987
1095
|
nobleInstance.on('discover', onDiscover);
|
|
988
1096
|
|
|
@@ -1008,6 +1116,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1008
1116
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
1009
1117
|
}
|
|
1010
1118
|
|
|
1119
|
+
assertBleActive();
|
|
1011
1120
|
// Capture noble reference for use in closures (TypeScript narrowing)
|
|
1012
1121
|
const nobleInstance = noble;
|
|
1013
1122
|
|
|
@@ -1022,12 +1131,15 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1022
1131
|
|
|
1023
1132
|
return new Promise((resolve, reject) => {
|
|
1024
1133
|
const devices: DeviceInfo[] = [];
|
|
1134
|
+
let settled = false;
|
|
1025
1135
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
1026
1136
|
|
|
1027
1137
|
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
1028
1138
|
// has stopped. Resolving enumerate before this callback creates a race with
|
|
1029
1139
|
// an immediately-following connection attempt.
|
|
1030
1140
|
const cleanup = async () => {
|
|
1141
|
+
settled = true;
|
|
1142
|
+
pendingCancellations.delete(cancel);
|
|
1031
1143
|
clearTimeout(timeoutId);
|
|
1032
1144
|
if (intervalId) clearInterval(intervalId);
|
|
1033
1145
|
await waitForNobleScanStop(nobleInstance);
|
|
@@ -1051,6 +1163,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1051
1163
|
|
|
1052
1164
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
1053
1165
|
const timeoutId = setTimeout(async () => {
|
|
1166
|
+
if (settled) return;
|
|
1054
1167
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
1055
1168
|
checkDevices();
|
|
1056
1169
|
await cleanup();
|
|
@@ -1058,11 +1171,17 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1058
1171
|
resolve(devices);
|
|
1059
1172
|
}, DEVICE_SCAN_TIMEOUT);
|
|
1060
1173
|
|
|
1174
|
+
const cancel = () => {
|
|
1175
|
+
cleanup().then(() => resolve([]), reject);
|
|
1176
|
+
};
|
|
1177
|
+
pendingCancellations.add(cancel);
|
|
1178
|
+
|
|
1061
1179
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
1062
1180
|
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
1063
1181
|
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
1064
1182
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
1065
1183
|
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
1184
|
+
if (settled) return;
|
|
1066
1185
|
if (error) {
|
|
1067
1186
|
await cleanup();
|
|
1068
1187
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
@@ -1153,13 +1272,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
1153
1272
|
async function discoverServicesAndCharacteristics(
|
|
1154
1273
|
peripheral: Peripheral
|
|
1155
1274
|
): Promise<CharacteristicPair> {
|
|
1275
|
+
assertBleActive();
|
|
1156
1276
|
// Cleanup resources - will be set up and cleaned in try/finally
|
|
1157
1277
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1158
1278
|
let onDisconnect: (() => void) | undefined;
|
|
1159
1279
|
|
|
1160
1280
|
const cleanup = () => {
|
|
1161
1281
|
if (timeoutId) clearTimeout(timeoutId);
|
|
1162
|
-
if (onDisconnect)
|
|
1282
|
+
if (onDisconnect) {
|
|
1283
|
+
peripheral.removeListener('disconnect', onDisconnect);
|
|
1284
|
+
pendingCancellations.delete(onDisconnect);
|
|
1285
|
+
}
|
|
1163
1286
|
};
|
|
1164
1287
|
|
|
1165
1288
|
// Racing promises for timeout and disconnect
|
|
@@ -1180,6 +1303,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1180
1303
|
)
|
|
1181
1304
|
);
|
|
1182
1305
|
};
|
|
1306
|
+
pendingCancellations.add(onDisconnect);
|
|
1183
1307
|
peripheral.once('disconnect', onDisconnect);
|
|
1184
1308
|
});
|
|
1185
1309
|
|
|
@@ -1205,6 +1329,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1205
1329
|
});
|
|
1206
1330
|
});
|
|
1207
1331
|
|
|
1332
|
+
assertBleActive();
|
|
1208
1333
|
if (!services || services.length === 0) {
|
|
1209
1334
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
|
|
1210
1335
|
}
|
|
@@ -1311,17 +1436,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1311
1436
|
}
|
|
1312
1437
|
|
|
1313
1438
|
// 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
|
-
});
|
|
1439
|
+
await connectPeripheralWithCancellation(peripheral, deviceId);
|
|
1323
1440
|
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1324
|
-
connectedDevices.set(deviceId, peripheral);
|
|
1325
1441
|
|
|
1326
1442
|
// Wait for connection to stabilize
|
|
1327
1443
|
await wait(500);
|
|
@@ -1357,16 +1473,11 @@ async function freshScanAndDiscover(
|
|
|
1357
1473
|
discoveredDevices.set(deviceId, freshPeripheral);
|
|
1358
1474
|
|
|
1359
1475
|
// Connect to fresh peripheral
|
|
1360
|
-
await
|
|
1361
|
-
freshPeripheral
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
connectedDevices.set(deviceId, freshPeripheral);
|
|
1366
|
-
resolve();
|
|
1367
|
-
}
|
|
1368
|
-
});
|
|
1369
|
-
});
|
|
1476
|
+
await connectPeripheralWithCancellation(
|
|
1477
|
+
freshPeripheral,
|
|
1478
|
+
deviceId,
|
|
1479
|
+
'Fresh peripheral connection failed: '
|
|
1480
|
+
);
|
|
1370
1481
|
|
|
1371
1482
|
// Setup disconnect listener for fresh peripheral
|
|
1372
1483
|
setupDisconnectListener(freshPeripheral, deviceId, webContents);
|
|
@@ -1438,6 +1549,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1438
1549
|
minTimeout: 500,
|
|
1439
1550
|
maxTimeout: 3000,
|
|
1440
1551
|
onFailedAttempt: error => {
|
|
1552
|
+
assertBleActive();
|
|
1441
1553
|
// This runs after each failed attempt
|
|
1442
1554
|
logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
|
|
1443
1555
|
message: error.message,
|
|
@@ -1473,13 +1585,17 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1473
1585
|
try {
|
|
1474
1586
|
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1475
1587
|
} catch (resetError) {
|
|
1476
|
-
if (
|
|
1588
|
+
if (
|
|
1589
|
+
isBleStaleBondHardwareError(resetError) ||
|
|
1590
|
+
(resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
|
|
1591
|
+
) {
|
|
1477
1592
|
throw resetError;
|
|
1478
1593
|
}
|
|
1479
1594
|
// A failed reset must not abort the attempt: discovery on the existing
|
|
1480
1595
|
// link, then the fresh-scan fallback, still have a chance to recover.
|
|
1481
1596
|
logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
|
|
1482
1597
|
}
|
|
1598
|
+
assertBleActive();
|
|
1483
1599
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1484
1600
|
|
|
1485
1601
|
try {
|
|
@@ -1522,6 +1638,7 @@ const directConnectCooldownUntil = new Map<string, number>();
|
|
|
1522
1638
|
* Ported from the Trezor connector, where it is field-proven.
|
|
1523
1639
|
*/
|
|
1524
1640
|
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1641
|
+
assertBleActive();
|
|
1525
1642
|
const nobleInstance = noble as
|
|
1526
1643
|
| (typeof noble & {
|
|
1527
1644
|
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
@@ -1532,16 +1649,20 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1532
1649
|
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1533
1650
|
if (Date.now() < cooldownUntil) return undefined;
|
|
1534
1651
|
|
|
1652
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1653
|
+
let cancel: (() => void) | undefined;
|
|
1535
1654
|
try {
|
|
1536
1655
|
// The late-orphan guard must attach to THIS pending connect.
|
|
1537
1656
|
const directPromise = nobleInstance.connectAsync(deviceId);
|
|
1538
1657
|
const raced = await Promise.race([
|
|
1539
1658
|
directPromise,
|
|
1540
1659
|
new Promise<'timeout'>(resolve => {
|
|
1541
|
-
|
|
1660
|
+
cancel = () => resolve('timeout');
|
|
1661
|
+
pendingCancellations.add(cancel);
|
|
1662
|
+
timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
|
|
1542
1663
|
}),
|
|
1543
1664
|
]);
|
|
1544
|
-
if (raced === 'timeout') {
|
|
1665
|
+
if (raced === 'timeout' || disposing) {
|
|
1545
1666
|
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1546
1667
|
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1547
1668
|
deviceId,
|
|
@@ -1574,6 +1695,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1574
1695
|
error: String(error),
|
|
1575
1696
|
});
|
|
1576
1697
|
return undefined;
|
|
1698
|
+
} finally {
|
|
1699
|
+
clearTimeout(timer);
|
|
1700
|
+
if (cancel) pendingCancellations.delete(cancel);
|
|
1577
1701
|
}
|
|
1578
1702
|
}
|
|
1579
1703
|
|
|
@@ -1644,6 +1768,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1644
1768
|
}
|
|
1645
1769
|
}
|
|
1646
1770
|
|
|
1771
|
+
assertBleActive();
|
|
1647
1772
|
// At this point, peripheral is guaranteed to be defined
|
|
1648
1773
|
if (!peripheral) {
|
|
1649
1774
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
|
|
@@ -1666,6 +1791,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1666
1791
|
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1667
1792
|
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1668
1793
|
// renderer, and the reuse fast path below returns before any other setup.
|
|
1794
|
+
assertBleActive();
|
|
1669
1795
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1670
1796
|
|
|
1671
1797
|
// Check if we already have characteristics for this device
|
|
@@ -1716,6 +1842,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1716
1842
|
deviceId,
|
|
1717
1843
|
webContents
|
|
1718
1844
|
);
|
|
1845
|
+
assertBleActive();
|
|
1719
1846
|
deviceCharacteristics.set(deviceId, characteristics);
|
|
1720
1847
|
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1721
1848
|
} catch (setupError) {
|
|
@@ -1727,51 +1854,42 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1727
1854
|
return;
|
|
1728
1855
|
}
|
|
1729
1856
|
|
|
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
|
-
}
|
|
1857
|
+
const connectedPeripheral = peripheral;
|
|
1858
|
+
await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
|
|
1859
|
+
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1739
1860
|
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
// Never reject from inside a raw disconnect callback: noble only fires
|
|
1756
|
-
// it on a real 'disconnect' event, so a peripheral that is already
|
|
1757
|
-
// down leaves this promise — and the renderer acquire awaiting it —
|
|
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
|
-
});
|
|
1861
|
+
// Setup connection and discover services
|
|
1862
|
+
try {
|
|
1863
|
+
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1864
|
+
connectedPeripheral,
|
|
1865
|
+
deviceId,
|
|
1866
|
+
webContents
|
|
1867
|
+
);
|
|
1868
|
+
assertBleActive();
|
|
1869
|
+
deviceCharacteristics.set(deviceId, characteristics);
|
|
1870
|
+
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1871
|
+
} catch (setupError) {
|
|
1872
|
+
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1873
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1874
|
+
throw setupError;
|
|
1875
|
+
}
|
|
1766
1876
|
}
|
|
1767
1877
|
|
|
1768
1878
|
// Disconnect device
|
|
1769
1879
|
async function disconnectDevice(deviceId: string): Promise<void> {
|
|
1770
|
-
const
|
|
1880
|
+
const pendingConnection = connectingDevices.get(deviceId);
|
|
1881
|
+
const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
|
|
1771
1882
|
if (!peripheral) {
|
|
1772
1883
|
return;
|
|
1773
1884
|
}
|
|
1774
1885
|
|
|
1886
|
+
pendingConnection?.cancel(
|
|
1887
|
+
ERRORS.TypedError(
|
|
1888
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
1889
|
+
`Device ${deviceId} connection cancelled`
|
|
1890
|
+
)
|
|
1891
|
+
);
|
|
1892
|
+
|
|
1775
1893
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1776
1894
|
if (disconnectEntry) {
|
|
1777
1895
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -1826,7 +1944,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1826
1944
|
subscribedDevices.delete(deviceId);
|
|
1827
1945
|
} finally {
|
|
1828
1946
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1829
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
1947
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1830
1948
|
}
|
|
1831
1949
|
}
|
|
1832
1950
|
|
|
@@ -1915,11 +2033,13 @@ async function subscribeNotifications(
|
|
|
1915
2033
|
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1916
2034
|
timeoutBehavior: 'resolve',
|
|
1917
2035
|
});
|
|
2036
|
+
assertBleActive();
|
|
1918
2037
|
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1919
2038
|
timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
1920
2039
|
timeoutBehavior: 'reject',
|
|
1921
2040
|
});
|
|
1922
2041
|
|
|
2042
|
+
assertBleActive();
|
|
1923
2043
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
1924
2044
|
// Windows BLE pairing detection: receiving any data means device is paired
|
|
1925
2045
|
if (!pairedDevices.has(deviceId)) {
|
|
@@ -1934,19 +2054,26 @@ async function subscribeNotifications(
|
|
|
1934
2054
|
const subscribeStartedAt = Date.now();
|
|
1935
2055
|
try {
|
|
1936
2056
|
await rebuildAppSubscription(deviceId, notifyCharacteristic);
|
|
2057
|
+
assertBleActive();
|
|
1937
2058
|
subscribedDevices.set(deviceId, true);
|
|
1938
2059
|
logger?.info('[NobleBLE] Notification subscription active', {
|
|
1939
2060
|
deviceId,
|
|
1940
2061
|
ms: Date.now() - subscribeStartedAt,
|
|
1941
2062
|
});
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
throw createNobleBleConnectionError(
|
|
2065
|
+
error as NobleBleNativeError,
|
|
2066
|
+
'Notification subscription failed: '
|
|
2067
|
+
);
|
|
1942
2068
|
} finally {
|
|
1943
2069
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1944
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2070
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1945
2071
|
}
|
|
1946
2072
|
}
|
|
1947
2073
|
|
|
1948
2074
|
// Setup IPC handlers
|
|
1949
2075
|
export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
2076
|
+
if (disposing) return;
|
|
1950
2077
|
try {
|
|
1951
2078
|
// @ts-ignore – electron-log is only available at runtime
|
|
1952
2079
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
@@ -1956,11 +2083,16 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1956
2083
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1957
2084
|
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1958
2085
|
|
|
2086
|
+
const channels = new Set<string>();
|
|
2087
|
+
removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
|
|
2088
|
+
|
|
1959
2089
|
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1960
2090
|
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
2091
|
+
channels.add(channel);
|
|
1961
2092
|
ipcMain.removeHandler(channel);
|
|
1962
2093
|
ipcMain.handle(channel, async (...args) => {
|
|
1963
2094
|
try {
|
|
2095
|
+
assertBleActive();
|
|
1964
2096
|
return await Promise.resolve(listener(...args));
|
|
1965
2097
|
} catch (error) {
|
|
1966
2098
|
return createNobleBleIpcErrorResponse(error);
|
|
@@ -2116,8 +2248,9 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2116
2248
|
}
|
|
2117
2249
|
});
|
|
2118
2250
|
|
|
2119
|
-
//
|
|
2251
|
+
// Window cleanup also runs during renderer soft restart; native release belongs to app quit.
|
|
2120
2252
|
webContents.on('destroyed', () => {
|
|
2253
|
+
if (disposing) return;
|
|
2121
2254
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
2122
2255
|
(async () => {
|
|
2123
2256
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
@@ -2147,3 +2280,65 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2147
2280
|
throw error;
|
|
2148
2281
|
}
|
|
2149
2282
|
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Terminal, bounded cleanup while the Node environment is still alive.
|
|
2286
|
+
* If Noble is shared with another transport, releaseNoble must queue the instance
|
|
2287
|
+
* and call stop() once after every transport has finished its cleanup.
|
|
2288
|
+
*/
|
|
2289
|
+
export function disposeNobleBleSupport(
|
|
2290
|
+
releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
|
|
2291
|
+
): Promise<void> {
|
|
2292
|
+
if (disposePromise) return disposePromise;
|
|
2293
|
+
disposing = true;
|
|
2294
|
+
removeIpcHandlers?.();
|
|
2295
|
+
const instance = noble;
|
|
2296
|
+
const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
|
|
2297
|
+
const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
|
|
2298
|
+
for (const cancel of pendingCancellations) cancel();
|
|
2299
|
+
for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
|
|
2300
|
+
for (const pending of connectingDevices.values()) {
|
|
2301
|
+
pending.cancel(
|
|
2302
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2305
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
2306
|
+
disposePromise = (async () => {
|
|
2307
|
+
try {
|
|
2308
|
+
await Promise.race([
|
|
2309
|
+
Promise.allSettled([
|
|
2310
|
+
stopScanning(),
|
|
2311
|
+
...pendingPeripherals.map(peripheral =>
|
|
2312
|
+
runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
2313
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
2314
|
+
timeoutBehavior: 'resolve',
|
|
2315
|
+
})
|
|
2316
|
+
),
|
|
2317
|
+
...Array.from(deviceIds, async id => {
|
|
2318
|
+
await unsubscribeNotifications(id).catch(() => undefined);
|
|
2319
|
+
await disconnectDevice(id).catch(() => undefined);
|
|
2320
|
+
}),
|
|
2321
|
+
]),
|
|
2322
|
+
new Promise<void>(resolve => {
|
|
2323
|
+
timeout = setTimeout(() => {
|
|
2324
|
+
logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
|
|
2325
|
+
resolve();
|
|
2326
|
+
}, 3500);
|
|
2327
|
+
}),
|
|
2328
|
+
]);
|
|
2329
|
+
} finally {
|
|
2330
|
+
clearTimeout(timeout);
|
|
2331
|
+
cleanupNobleListeners();
|
|
2332
|
+
if (instance && persistentStateListener) {
|
|
2333
|
+
instance.removeListener('stateChange', persistentStateListener);
|
|
2334
|
+
persistentStateListener = null;
|
|
2335
|
+
}
|
|
2336
|
+
for (const id of deviceIds) cleanupDevice(id);
|
|
2337
|
+
discoveredDevices.clear();
|
|
2338
|
+
directConnectCooldownUntil.clear();
|
|
2339
|
+
if (instance) releaseNoble(instance);
|
|
2340
|
+
logger?.info('[NobleBLE] Process dispose completed');
|
|
2341
|
+
}
|
|
2342
|
+
})();
|
|
2343
|
+
return disposePromise;
|
|
2344
|
+
}
|
package/src/types/desktop-api.ts
CHANGED
|
@@ -23,6 +23,37 @@ export type NobleBleIpcErrorResponse = {
|
|
|
23
23
|
error: NobleBleIpcErrorPayload;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
export function isNobleBleIpcErrorResponse(
|
|
27
|
+
response: unknown
|
|
28
|
+
): response is NobleBleIpcErrorResponse {
|
|
29
|
+
if (!response || typeof response !== 'object') {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const candidate = response as Partial<NobleBleIpcErrorResponse>;
|
|
33
|
+
return (
|
|
34
|
+
candidate.type === 'NobleBleIpcError' &&
|
|
35
|
+
candidate.success === false &&
|
|
36
|
+
Boolean(candidate.error) &&
|
|
37
|
+
typeof candidate.error?.name === 'string' &&
|
|
38
|
+
typeof candidate.error.message === 'string' &&
|
|
39
|
+
typeof candidate.error.errorCode === 'number'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Keeps structured Noble errors intact across Electron's IPC and contextBridge
|
|
45
|
+
* boundaries. Every preload implementation must use this for Noble invokes.
|
|
46
|
+
*/
|
|
47
|
+
export async function invokeNobleBleIpc<T>(
|
|
48
|
+
request: Promise<T | NobleBleIpcErrorResponse>
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
const response = await request;
|
|
51
|
+
if (isNobleBleIpcErrorResponse(response)) {
|
|
52
|
+
return Promise.reject(response.error);
|
|
53
|
+
}
|
|
54
|
+
return response;
|
|
55
|
+
}
|
|
56
|
+
|
|
26
57
|
export interface NobleBleAPI {
|
|
27
58
|
enumerate: () => Promise<{ id: string; name: string }[]>;
|
|
28
59
|
getDevice: (uuid: string) => Promise<{ id: string; name: string; mtu?: number } | null>;
|
|
@@ -29,6 +29,7 @@ export interface NobleModule {
|
|
|
29
29
|
callback?: (error?: Error) => void
|
|
30
30
|
): void;
|
|
31
31
|
stopScanning(callback?: () => void): void;
|
|
32
|
+
stop(): void;
|
|
32
33
|
on(event: 'stateChange', listener: (state: string) => void): void;
|
|
33
34
|
on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
|
|
34
35
|
removeListener(event: 'stateChange', listener: (state: string) => void): void;
|