@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.
@@ -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) peripheral.removeListener('disconnect', 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 new Promise<void>((resolve, reject) => {
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 new Promise<void>((resolve, reject) => {
1361
- freshPeripheral.connect((error: Error | undefined) => {
1362
- if (error) {
1363
- reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
1364
- } else {
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 (isBleStaleBondHardwareError(resetError)) {
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
- setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
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
- return new Promise((resolve, reject) => {
1731
- // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1732
- const connectedPeripheral = peripheral as Peripheral;
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
- logger?.info('[NobleBLE] Connected to device:', deviceId);
1741
- connectedDevices.set(deviceId, connectedPeripheral);
1742
-
1743
- // Setup connection and discover services
1744
- try {
1745
- const characteristics = await setupConnectionAndDiscoverServices(
1746
- connectedPeripheral,
1747
- deviceId,
1748
- webContents
1749
- );
1750
- deviceCharacteristics.set(deviceId, characteristics);
1751
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1752
- resolve();
1753
- } catch (setupError) {
1754
- logger?.error('[NobleBLE] Connection setup failed:', setupError);
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 peripheral = connectedDevices.get(deviceId);
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
- // Cleanup on app quit
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
+ }
@@ -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;