@onekeyfe/hd-transport-electron 1.2.2-alpha.9 → 1.2.3-alpha.2

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.
@@ -10,11 +10,13 @@ import {
10
10
  EOneKeyBleMessageKeys,
11
11
  ERRORS,
12
12
  HardwareErrorCode,
13
+ HardwareErrorCodeMessage,
13
14
  ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
14
15
  ONEKEY_SERVICE_UUID,
15
16
  ONEKEY_WRITE_CHARACTERISTIC_UUID,
16
17
  createKnownBleUuidAliases,
17
18
  hasOnekeyCommunicationService,
19
+ isBleStaleBondHardwareError,
18
20
  isOnekeyBluetoothDevice,
19
21
  isPro2FamilyBleName,
20
22
  matchesKnownBleUuid,
@@ -26,18 +28,102 @@ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capac
26
28
  import { safeLog } from './types/noble-extended';
27
29
  import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
28
30
  import {
29
- NOBLE_BLE_CONNECTION_TIMEOUT_MS,
31
+ NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
30
32
  NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
31
33
  } from './noble-ble-timeouts';
32
34
 
33
35
  import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
34
36
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
35
- import type { NobleBleWriteOptions } from './types/desktop-api';
37
+ import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
36
38
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
37
39
 
38
40
  // Noble will be dynamically imported to avoid bundling issues
39
41
  let noble: NobleModule | null = null;
40
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 pendingEnumerations = new Set<() => Promise<void>>();
50
+ const nativeConnections = new Set<{ cancel: () => void; settled: Promise<void> }>();
51
+
52
+ function trackNativeConnection(operation: Promise<unknown>, cancel: () => void): void {
53
+ const connection = {
54
+ cancel,
55
+ settled: operation.then(
56
+ () => undefined,
57
+ () => undefined
58
+ ),
59
+ };
60
+ nativeConnections.add(connection);
61
+ connection.settled = connection.settled.finally(() => nativeConnections.delete(connection));
62
+ }
63
+
64
+ function assertBleActive(): void {
65
+ if (disposing) {
66
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
67
+ }
68
+ }
69
+
70
+ type NobleBleNativeError = Error & {
71
+ nativeErrorCode?: number;
72
+ nativeErrorDomain?: string;
73
+ };
74
+
75
+ export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
76
+ const errorMessage = error.message;
77
+ const isInvalidMacOsBond =
78
+ (error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
79
+ (error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
80
+ if (isInvalidMacOsBond) {
81
+ const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
82
+ return ERRORS.TypedError(
83
+ HardwareErrorCode.BleBondInvalid,
84
+ `${HardwareErrorCodeMessage[HardwareErrorCode.BleBondInvalid]} (${nativeErrorMessage})`,
85
+ {
86
+ nativeErrorMessage,
87
+ }
88
+ );
89
+ }
90
+
91
+ return ERRORS.TypedError(HardwareErrorCode.BleConnectedError, `${messagePrefix}${errorMessage}`);
92
+ }
93
+
94
+ export function createNobleBleIpcErrorResponse(error: unknown): NobleBleIpcErrorResponse {
95
+ const candidate = error as {
96
+ errorCode?: unknown;
97
+ message?: unknown;
98
+ name?: unknown;
99
+ params?: unknown;
100
+ };
101
+ const errorCode =
102
+ typeof candidate?.errorCode === 'number' ? candidate.errorCode : HardwareErrorCode.UnknownError;
103
+ const message =
104
+ typeof candidate?.message === 'string' ? candidate.message : String(error ?? 'Unknown error');
105
+ const name = typeof candidate?.name === 'string' ? candidate.name : 'Error';
106
+ let params: unknown;
107
+ if (candidate?.params !== undefined) {
108
+ try {
109
+ const serializedParams = JSON.stringify(candidate.params);
110
+ params = serializedParams === undefined ? undefined : JSON.parse(serializedParams);
111
+ } catch {
112
+ params = undefined;
113
+ }
114
+ }
115
+
116
+ return {
117
+ type: 'NobleBleIpcError',
118
+ success: false,
119
+ error: {
120
+ name,
121
+ message,
122
+ errorCode,
123
+ ...(params !== undefined ? { params } : {}),
124
+ },
125
+ };
126
+ }
41
127
 
42
128
  // Bluetooth state management
43
129
  const bluetoothState: {
@@ -57,6 +143,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
57
143
  // Device cache and connection state
58
144
  const discoveredDevices = new Map<string, Peripheral>();
59
145
  const connectedDevices = new Map<string, Peripheral>();
146
+ type PendingDeviceConnection = {
147
+ peripheral: Peripheral;
148
+ generation: number;
149
+ cancel: (error: unknown) => void;
150
+ };
151
+ const connectingDevices = new Map<string, PendingDeviceConnection>();
152
+ let nextConnectionGeneration = 0;
153
+
154
+ function connectPeripheralWithCancellation(
155
+ peripheral: Peripheral,
156
+ deviceId: string,
157
+ messagePrefix = ''
158
+ ): Promise<void> {
159
+ assertBleActive();
160
+ return new Promise<void>((resolve, reject) => {
161
+ const generation = ++nextConnectionGeneration;
162
+ let settled = false;
163
+ const settle = (settler: () => void): boolean => {
164
+ if (settled) return false;
165
+ settled = true;
166
+ if (connectingDevices.get(deviceId)?.generation === generation) {
167
+ connectingDevices.delete(deviceId);
168
+ }
169
+ settler();
170
+ return true;
171
+ };
172
+ const pendingConnection: PendingDeviceConnection = {
173
+ peripheral,
174
+ generation,
175
+ cancel: error => {
176
+ settle(() => reject(error));
177
+ },
178
+ };
179
+ connectingDevices
180
+ .get(deviceId)
181
+ ?.cancel(
182
+ ERRORS.TypedError(
183
+ HardwareErrorCode.BleDeviceDisconnected,
184
+ `Device ${deviceId} connect attempt was superseded`
185
+ )
186
+ );
187
+ connectingDevices.set(deviceId, pendingConnection);
188
+
189
+ let completeNative!: () => void;
190
+ trackNativeConnection(
191
+ new Promise<void>(resolve => {
192
+ completeNative = resolve;
193
+ }),
194
+ () => {
195
+ if (typeof peripheral.cancelConnect === 'function') peripheral.cancelConnect();
196
+ else noble?.cancelConnect?.(deviceId);
197
+ }
198
+ );
199
+ const onConnect = (error?: Error | null) => {
200
+ if (nativeReleased) {
201
+ completeNative();
202
+ return;
203
+ }
204
+ if (error) {
205
+ settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
206
+ completeNative();
207
+ return;
208
+ }
209
+
210
+ if (
211
+ !settle(() => {
212
+ connectedDevices.set(deviceId, peripheral);
213
+ resolve();
214
+ })
215
+ ) {
216
+ const activeConnection = connectingDevices.get(deviceId);
217
+ if (!activeConnection || activeConnection.peripheral !== peripheral) {
218
+ try {
219
+ peripheral.removeAllListeners('disconnect');
220
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
221
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
222
+ timeoutBehavior: 'resolve',
223
+ })
224
+ .catch(() => undefined)
225
+ .finally(completeNative);
226
+ return;
227
+ } catch {
228
+ // Best-effort cleanup for a stale native callback.
229
+ }
230
+ }
231
+ }
232
+ completeNative();
233
+ };
234
+ try {
235
+ peripheral.connect(onConnect);
236
+ } catch (error) {
237
+ completeNative();
238
+ settle(() => reject(error));
239
+ }
240
+ });
241
+ }
60
242
  const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
61
243
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
62
244
  const notificationCallbacks = new Map<string, (data: string) => void>();
@@ -261,6 +443,7 @@ function updateBluetoothState(state: string): void {
261
443
 
262
444
  // Initialize Noble
263
445
  async function initializeNoble(): Promise<void> {
446
+ assertBleActive();
264
447
  if (noble) return;
265
448
 
266
449
  try {
@@ -286,12 +469,14 @@ async function initializeNoble(): Promise<void> {
286
469
  }
287
470
 
288
471
  const timeout = setTimeout(() => {
472
+ cleanup();
289
473
  reject(
290
474
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
291
475
  );
292
476
  }, BLUETOOTH_INIT_TIMEOUT);
293
477
 
294
478
  const cleanup = () => {
479
+ pendingCancellations.delete(cancel);
295
480
  clearTimeout(timeout);
296
481
  if (noble) {
297
482
  noble.removeListener('stateChange', onStateChange);
@@ -316,9 +501,17 @@ async function initializeNoble(): Promise<void> {
316
501
  }
317
502
  };
318
503
 
504
+ const cancel = () => {
505
+ cleanup();
506
+ reject(
507
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
508
+ );
509
+ };
510
+ pendingCancellations.add(cancel);
319
511
  noble.on('stateChange', onStateChange);
320
512
  });
321
513
 
514
+ assertBleActive();
322
515
  // Set up device discovery
323
516
  if (!persistentDiscoverListener) {
324
517
  persistentDiscoverListener = (peripheral: Peripheral) => {
@@ -403,6 +596,7 @@ function armIdleDisconnect(
403
596
  reason: 'idle' | 'busy-backstop' = 'idle'
404
597
  ): void {
405
598
  clearIdleDisconnect(deviceId);
599
+ if (disposing) return;
406
600
  idleDisconnectTimers.set(
407
601
  deviceId,
408
602
  setTimeout(() => {
@@ -485,6 +679,14 @@ function cleanupDevice(
485
679
 
486
680
  // 1. Clean up connection state
487
681
  if (cleanupConnection) {
682
+ connectingDevices
683
+ .get(deviceId)
684
+ ?.cancel(
685
+ ERRORS.TypedError(
686
+ HardwareErrorCode.BleDeviceDisconnected,
687
+ `Device ${deviceId} disconnected while connecting`
688
+ )
689
+ );
488
690
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
489
691
  if (disconnectEntry) {
490
692
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -594,6 +796,7 @@ async function writeCharacteristicWithoutResponse(
594
796
  writeCharacteristic: Characteristic,
595
797
  buffer: Buffer
596
798
  ): Promise<void> {
799
+ assertBleActive();
597
800
  return new Promise((resolve, reject) => {
598
801
  writeCharacteristic.write(buffer, true, (error?: Error) => {
599
802
  if (error) {
@@ -878,6 +1081,7 @@ async function performTargetedScan(
878
1081
  targetDeviceId: string,
879
1082
  timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
880
1083
  ): Promise<Peripheral | null> {
1084
+ assertBleActive();
881
1085
  if (!noble) {
882
1086
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
883
1087
  }
@@ -893,6 +1097,7 @@ async function performTargetedScan(
893
1097
  const finish = async (peripheral: Peripheral | null, error?: Error) => {
894
1098
  if (settled) return;
895
1099
  settled = true;
1100
+ pendingCancellations.delete(cancel);
896
1101
  if (timeoutId) clearTimeout(timeoutId);
897
1102
  nobleInstance.removeListener('discover', onDiscover);
898
1103
  await waitForNobleScanStop(nobleInstance);
@@ -902,6 +1107,7 @@ async function performTargetedScan(
902
1107
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
903
1108
  return;
904
1109
  }
1110
+ assertBleActive();
905
1111
  if (peripheral) {
906
1112
  discoveredDevices.set(peripheral.id, peripheral);
907
1113
  }
@@ -924,6 +1130,11 @@ async function performTargetedScan(
924
1130
  finish(null).catch(reject);
925
1131
  }, timeoutMs);
926
1132
 
1133
+ const cancel = () => {
1134
+ finish(null, new Error('Noble BLE is shutting down')).catch(reject);
1135
+ };
1136
+ pendingCancellations.add(cancel);
1137
+
927
1138
  // Add local listener for this scan
928
1139
  nobleInstance.on('discover', onDiscover);
929
1140
 
@@ -940,7 +1151,7 @@ async function performTargetedScan(
940
1151
  }
941
1152
 
942
1153
  // Enumerate devices
943
- async function enumerateDevices(): Promise<DeviceInfo[]> {
1154
+ async function enumerateDevices(isWindowDestroyed: () => boolean): Promise<DeviceInfo[]> {
944
1155
  if (!noble) {
945
1156
  await initializeNoble();
946
1157
  }
@@ -949,6 +1160,8 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
949
1160
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
950
1161
  }
951
1162
 
1163
+ assertBleActive();
1164
+ if (isWindowDestroyed()) return [];
952
1165
  // Capture noble reference for use in closures (TypeScript narrowing)
953
1166
  const nobleInstance = noble;
954
1167
 
@@ -963,15 +1176,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
963
1176
 
964
1177
  return new Promise((resolve, reject) => {
965
1178
  const devices: DeviceInfo[] = [];
1179
+ let settled = false;
966
1180
  let intervalId: ReturnType<typeof setInterval> | undefined;
1181
+ let cleanupPromise: Promise<void> | undefined;
967
1182
 
968
1183
  // Cleanup function: clears timers and waits until Noble confirms scanning
969
1184
  // has stopped. Resolving enumerate before this callback creates a race with
970
1185
  // an immediately-following connection attempt.
971
- const cleanup = async () => {
972
- clearTimeout(timeoutId);
973
- if (intervalId) clearInterval(intervalId);
974
- await waitForNobleScanStop(nobleInstance);
1186
+ const cleanup = () => {
1187
+ if (!cleanupPromise) {
1188
+ settled = true;
1189
+ pendingCancellations.delete(cancel);
1190
+ clearTimeout(timeoutId);
1191
+ if (intervalId) clearInterval(intervalId);
1192
+ cleanupPromise = waitForNobleScanStop(nobleInstance).finally(() => {
1193
+ pendingEnumerations.delete(cancel);
1194
+ });
1195
+ }
1196
+ return cleanupPromise;
975
1197
  };
976
1198
 
977
1199
  // Collect discovered devices into the devices array
@@ -992,6 +1214,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
992
1214
 
993
1215
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
994
1216
  const timeoutId = setTimeout(async () => {
1217
+ if (settled) return;
995
1218
  // Final collection before resolving — catches devices discovered near the deadline
996
1219
  checkDevices();
997
1220
  await cleanup();
@@ -999,11 +1222,16 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
999
1222
  resolve(devices);
1000
1223
  }, DEVICE_SCAN_TIMEOUT);
1001
1224
 
1225
+ const cancel = () => cleanup().then(() => resolve([]), reject);
1226
+ pendingCancellations.add(cancel);
1227
+ pendingEnumerations.add(cancel);
1228
+
1002
1229
  // Start scanning without a service UUID filter so Pro2 advertisements with
1003
1230
  // short vendor UUIDs can be found. Repeated advertisements are required when
1004
1231
  // the local name arrives in a later scan response; discoveredDevices handles deduplication.
1005
1232
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
1006
1233
  nobleInstance.startScanning([], true, async (error?: Error) => {
1234
+ if (settled) return;
1007
1235
  if (error) {
1008
1236
  await cleanup();
1009
1237
  logger?.error('[NobleBLE] Failed to start scanning:', error);
@@ -1021,7 +1249,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1021
1249
 
1022
1250
  // Stop scanning
1023
1251
  async function stopScanning(): Promise<void> {
1024
- if (!noble) return;
1252
+ if (!noble || nativeReleased) return;
1025
1253
  const nobleInstance = noble;
1026
1254
  await waitForNobleScanStop(nobleInstance);
1027
1255
  logger?.info('[NobleBLE] Scanning stopped');
@@ -1094,13 +1322,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
1094
1322
  async function discoverServicesAndCharacteristics(
1095
1323
  peripheral: Peripheral
1096
1324
  ): Promise<CharacteristicPair> {
1325
+ assertBleActive();
1097
1326
  // Cleanup resources - will be set up and cleaned in try/finally
1098
1327
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
1099
1328
  let onDisconnect: (() => void) | undefined;
1100
1329
 
1101
1330
  const cleanup = () => {
1102
1331
  if (timeoutId) clearTimeout(timeoutId);
1103
- if (onDisconnect) peripheral.removeListener('disconnect', onDisconnect);
1332
+ if (onDisconnect) {
1333
+ peripheral.removeListener('disconnect', onDisconnect);
1334
+ pendingCancellations.delete(onDisconnect);
1335
+ }
1104
1336
  };
1105
1337
 
1106
1338
  // Racing promises for timeout and disconnect
@@ -1121,6 +1353,7 @@ async function discoverServicesAndCharacteristics(
1121
1353
  )
1122
1354
  );
1123
1355
  };
1356
+ pendingCancellations.add(onDisconnect);
1124
1357
  peripheral.once('disconnect', onDisconnect);
1125
1358
  });
1126
1359
 
@@ -1146,6 +1379,7 @@ async function discoverServicesAndCharacteristics(
1146
1379
  });
1147
1380
  });
1148
1381
 
1382
+ assertBleActive();
1149
1383
  if (!services || services.length === 0) {
1150
1384
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1151
1385
  }
@@ -1252,12 +1486,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1252
1486
  }
1253
1487
 
1254
1488
  // Step 3: Re-establish connection
1255
- await runBleCallbackOperation(callback => peripheral.connect(callback), {
1256
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1257
- timeoutBehavior: 'reject',
1258
- });
1489
+ await connectPeripheralWithCancellation(peripheral, deviceId);
1259
1490
  logger?.info('[NobleBLE] Force reconnect successful');
1260
- connectedDevices.set(deviceId, peripheral);
1261
1491
 
1262
1492
  // Wait for connection to stabilize
1263
1493
  await wait(500);
@@ -1293,21 +1523,11 @@ async function freshScanAndDiscover(
1293
1523
  discoveredDevices.set(deviceId, freshPeripheral);
1294
1524
 
1295
1525
  // Connect to fresh peripheral
1296
- await new Promise<void>((resolve, reject) => {
1297
- freshPeripheral.connect((error: Error | undefined) => {
1298
- if (error) {
1299
- reject(
1300
- ERRORS.TypedError(
1301
- HardwareErrorCode.BleConnectedError,
1302
- `Fresh peripheral connection failed: ${error.message}`
1303
- )
1304
- );
1305
- } else {
1306
- connectedDevices.set(deviceId, freshPeripheral);
1307
- resolve();
1308
- }
1309
- });
1310
- });
1526
+ await connectPeripheralWithCancellation(
1527
+ freshPeripheral,
1528
+ deviceId,
1529
+ 'Fresh peripheral connection failed: '
1530
+ );
1311
1531
 
1312
1532
  // Setup disconnect listener for fresh peripheral
1313
1533
  setupDisconnectListener(freshPeripheral, deviceId, webContents);
@@ -1379,6 +1599,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
1379
1599
  minTimeout: 500,
1380
1600
  maxTimeout: 3000,
1381
1601
  onFailedAttempt: error => {
1602
+ assertBleActive();
1382
1603
  // This runs after each failed attempt
1383
1604
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
1384
1605
  message: error.message,
@@ -1414,10 +1635,17 @@ async function setupConnectionAndDiscoverServices(
1414
1635
  try {
1415
1636
  await forceReconnectPeripheral(peripheral, deviceId);
1416
1637
  } catch (resetError) {
1638
+ if (
1639
+ isBleStaleBondHardwareError(resetError) ||
1640
+ (resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
1641
+ ) {
1642
+ throw resetError;
1643
+ }
1417
1644
  // A failed reset must not abort the attempt: discovery on the existing
1418
1645
  // link, then the fresh-scan fallback, still have a chance to recover.
1419
1646
  logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1420
1647
  }
1648
+ assertBleActive();
1421
1649
  setupDisconnectListener(peripheral, deviceId, webContents);
1422
1650
 
1423
1651
  try {
@@ -1460,6 +1688,7 @@ const directConnectCooldownUntil = new Map<string, number>();
1460
1688
  * Ported from the Trezor connector, where it is field-proven.
1461
1689
  */
1462
1690
  async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1691
+ assertBleActive();
1463
1692
  const nobleInstance = noble as
1464
1693
  | (typeof noble & {
1465
1694
  connectAsync?: (id: string) => Promise<Peripheral | undefined>;
@@ -1470,34 +1699,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1470
1699
  const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1471
1700
  if (Date.now() < cooldownUntil) return undefined;
1472
1701
 
1702
+ let timer: ReturnType<typeof setTimeout> | undefined;
1703
+ let cancel: (() => void) | undefined;
1704
+ let abandoned = false;
1473
1705
  try {
1474
1706
  // The late-orphan guard must attach to THIS pending connect.
1475
1707
  const directPromise = nobleInstance.connectAsync(deviceId);
1708
+ trackNativeConnection(
1709
+ directPromise.then(async late => {
1710
+ if ((!abandoned && !disposing) || nativeReleased) return;
1711
+ const peripheral = late ?? discoveredDevices.get(deviceId);
1712
+ if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
1713
+ peripheral.removeAllListeners('disconnect');
1714
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1715
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1716
+ timeoutBehavior: 'resolve',
1717
+ });
1718
+ }
1719
+ }),
1720
+ () => nobleInstance.cancelConnect?.(deviceId)
1721
+ );
1476
1722
  const raced = await Promise.race([
1477
1723
  directPromise,
1478
1724
  new Promise<'timeout'>(resolve => {
1479
- setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1725
+ cancel = () => {
1726
+ abandoned = true;
1727
+ resolve('timeout');
1728
+ };
1729
+ pendingCancellations.add(cancel);
1730
+ timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
1480
1731
  }),
1481
1732
  ]);
1482
- if (raced === 'timeout') {
1733
+ if (raced === 'timeout' || disposing) {
1483
1734
  directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1484
1735
  logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1485
1736
  deviceId,
1486
1737
  });
1487
- // Promise.race times out the caller only; a late success orphans the link.
1488
- directPromise
1489
- .then(late => {
1490
- const latePeripheral = late ?? discoveredDevices.get(deviceId);
1491
- if (
1492
- latePeripheral &&
1493
- latePeripheral.state === 'connected' &&
1494
- !connectedDevices.has(deviceId)
1495
- ) {
1496
- latePeripheral.removeAllListeners('disconnect');
1497
- latePeripheral.disconnect(() => undefined);
1498
- }
1499
- })
1500
- .catch(() => undefined);
1501
1738
  return undefined;
1502
1739
  }
1503
1740
  // Backends emit `discover` as a side effect, so the cache may hold it.
@@ -1512,6 +1749,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1512
1749
  error: String(error),
1513
1750
  });
1514
1751
  return undefined;
1752
+ } finally {
1753
+ clearTimeout(timer);
1754
+ if (cancel) pendingCancellations.delete(cancel);
1515
1755
  }
1516
1756
  }
1517
1757
 
@@ -1582,6 +1822,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1582
1822
  }
1583
1823
  }
1584
1824
 
1825
+ assertBleActive();
1585
1826
  // At this point, peripheral is guaranteed to be defined
1586
1827
  if (!peripheral) {
1587
1828
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
@@ -1604,6 +1845,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1604
1845
  // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1605
1846
  // and MTU listeners may still hold the webContents of a soft-restarted
1606
1847
  // renderer, and the reuse fast path below returns before any other setup.
1848
+ assertBleActive();
1607
1849
  setupDisconnectListener(peripheral, deviceId, webContents);
1608
1850
 
1609
1851
  // Check if we already have characteristics for this device
@@ -1654,6 +1896,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1654
1896
  deviceId,
1655
1897
  webContents
1656
1898
  );
1899
+ assertBleActive();
1657
1900
  deviceCharacteristics.set(deviceId, characteristics);
1658
1901
  logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1659
1902
  } catch (setupError) {
@@ -1665,72 +1908,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1665
1908
  return;
1666
1909
  }
1667
1910
 
1668
- return new Promise((resolve, reject) => {
1669
- let connectionTimedOut = false;
1670
- const timeout = setTimeout(() => {
1671
- connectionTimedOut = true;
1672
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1673
- }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1674
-
1675
- // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1676
- const connectedPeripheral = peripheral as Peripheral;
1677
- connectedPeripheral.connect(async (error: Error | undefined) => {
1678
- clearTimeout(timeout);
1911
+ const connectedPeripheral = peripheral;
1912
+ await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
1913
+ logger?.info('[NobleBLE] Connected to device:', deviceId);
1679
1914
 
1680
- // Noble may invoke the callback after the SDK timed out and released the request.
1681
- // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1682
- if (connectionTimedOut) {
1683
- if (!error) {
1684
- try {
1685
- connectedPeripheral.disconnect(() => undefined);
1686
- } catch {
1687
- // Best-effort cleanup only.
1688
- }
1689
- }
1690
- return;
1691
- }
1692
-
1693
- if (error) {
1694
- logger?.error('[NobleBLE] Connection failed:', error);
1695
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1696
- return;
1697
- }
1698
-
1699
- logger?.info('[NobleBLE] Connected to device:', deviceId);
1700
- connectedDevices.set(deviceId, connectedPeripheral);
1701
-
1702
- // Setup connection and discover services
1703
- try {
1704
- const characteristics = await setupConnectionAndDiscoverServices(
1705
- connectedPeripheral,
1706
- deviceId,
1707
- webContents
1708
- );
1709
- deviceCharacteristics.set(deviceId, characteristics);
1710
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1711
- resolve();
1712
- } catch (setupError) {
1713
- logger?.error('[NobleBLE] Connection setup failed:', setupError);
1714
- // Never reject from inside a raw disconnect callback: noble only fires
1715
- // it on a real 'disconnect' event, so a peripheral that is already
1716
- // down leaves this promise — and the renderer acquire awaiting it —
1717
- // pending forever. disconnectDevice always settles (it no-ops on an
1718
- // unknown peripheral and caps the confirm wait).
1719
- disconnectDevice(deviceId)
1720
- .catch(() => undefined)
1721
- .then(() => reject(setupError));
1722
- }
1723
- });
1724
- });
1915
+ // Setup connection and discover services
1916
+ try {
1917
+ const characteristics = await setupConnectionAndDiscoverServices(
1918
+ connectedPeripheral,
1919
+ deviceId,
1920
+ webContents
1921
+ );
1922
+ assertBleActive();
1923
+ deviceCharacteristics.set(deviceId, characteristics);
1924
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1925
+ } catch (setupError) {
1926
+ logger?.error('[NobleBLE] Connection setup failed:', setupError);
1927
+ await disconnectDevice(deviceId).catch(() => undefined);
1928
+ throw setupError;
1929
+ }
1725
1930
  }
1726
1931
 
1727
1932
  // Disconnect device
1728
1933
  async function disconnectDevice(deviceId: string): Promise<void> {
1729
- const peripheral = connectedDevices.get(deviceId);
1934
+ if (nativeReleased) return;
1935
+ const pendingConnection = connectingDevices.get(deviceId);
1936
+ const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
1730
1937
  if (!peripheral) {
1731
1938
  return;
1732
1939
  }
1733
1940
 
1941
+ pendingConnection?.cancel(
1942
+ ERRORS.TypedError(
1943
+ HardwareErrorCode.BleDeviceDisconnected,
1944
+ `Device ${deviceId} connection cancelled`
1945
+ )
1946
+ );
1947
+
1734
1948
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1735
1949
  if (disconnectEntry) {
1736
1950
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -1758,6 +1972,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1758
1972
 
1759
1973
  // Unsubscribe from notifications
1760
1974
  async function unsubscribeNotifications(deviceId: string): Promise<void> {
1975
+ if (nativeReleased) return;
1761
1976
  const peripheral = connectedDevices.get(deviceId);
1762
1977
  const characteristics = deviceCharacteristics.get(deviceId);
1763
1978
 
@@ -1785,7 +2000,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1785
2000
  subscribedDevices.delete(deviceId);
1786
2001
  } finally {
1787
2002
  // 🔒 CRITICAL: Always clear operation state (even on error)
1788
- subscriptionOperations.set(deviceId, 'idle');
2003
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1789
2004
  }
1790
2005
  }
1791
2006
 
@@ -1874,11 +2089,13 @@ async function subscribeNotifications(
1874
2089
  timeoutMs: BLE_CLEANUP_TIMEOUT,
1875
2090
  timeoutBehavior: 'resolve',
1876
2091
  });
2092
+ assertBleActive();
1877
2093
  await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1878
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
2094
+ timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
1879
2095
  timeoutBehavior: 'reject',
1880
2096
  });
1881
2097
 
2098
+ assertBleActive();
1882
2099
  notifyCharacteristic.on('data', (data: Buffer) => {
1883
2100
  // Windows BLE pairing detection: receiving any data means device is paired
1884
2101
  if (!pairedDevices.has(deviceId)) {
@@ -1893,19 +2110,27 @@ async function subscribeNotifications(
1893
2110
  const subscribeStartedAt = Date.now();
1894
2111
  try {
1895
2112
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
2113
+ assertBleActive();
1896
2114
  subscribedDevices.set(deviceId, true);
1897
2115
  logger?.info('[NobleBLE] Notification subscription active', {
1898
2116
  deviceId,
1899
2117
  ms: Date.now() - subscribeStartedAt,
1900
2118
  });
2119
+ } catch (error) {
2120
+ throw createNobleBleConnectionError(
2121
+ error as NobleBleNativeError,
2122
+ 'Notification subscription failed: '
2123
+ );
1901
2124
  } finally {
1902
2125
  // 🔒 CRITICAL: Always clear operation state (even on error)
1903
- subscriptionOperations.set(deviceId, 'idle');
2126
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1904
2127
  }
1905
2128
  }
1906
2129
 
1907
2130
  // Setup IPC handlers
1908
2131
  export function setupNobleBleHandlers(webContents: WebContents): void {
2132
+ if (disposing) return;
2133
+ let windowDestroyed = false;
1909
2134
  try {
1910
2135
  // @ts-ignore – electron-log is only available at runtime
1911
2136
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
@@ -1915,10 +2140,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1915
2140
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1916
2141
  const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1917
2142
 
2143
+ const channels = new Set<string>();
2144
+ removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
2145
+
1918
2146
  // Electron throws on duplicate channels and setup re-runs on soft restart.
1919
2147
  const handle: IpcMain['handle'] = (channel, listener) => {
2148
+ channels.add(channel);
1920
2149
  ipcMain.removeHandler(channel);
1921
- ipcMain.handle(channel, listener);
2150
+ ipcMain.handle(channel, async (...args) => {
2151
+ try {
2152
+ // A replacement renderer must not race the old renderer's shared-state cleanup.
2153
+ if (windowCleanup) await windowCleanup;
2154
+ assertBleActive();
2155
+ if (windowDestroyed) {
2156
+ throw ERRORS.TypedError(
2157
+ HardwareErrorCode.BleDeviceDisconnected,
2158
+ 'BLE window destroyed'
2159
+ );
2160
+ }
2161
+ return await Promise.resolve(listener(...args));
2162
+ } catch (error) {
2163
+ return createNobleBleIpcErrorResponse(error);
2164
+ }
2165
+ });
1922
2166
  };
1923
2167
 
1924
2168
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
@@ -1926,7 +2170,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1926
2170
  // Handle enumerate request
1927
2171
  handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1928
2172
  try {
1929
- const devices = await enumerateDevices();
2173
+ const devices = await enumerateDevices(() => windowDestroyed);
1930
2174
  safeLog(logger, 'debug', 'Enumeration completed', {
1931
2175
  count: devices.length,
1932
2176
  devices: devices.map(device => ({ id: device.id, name: device.name })),
@@ -2069,16 +2313,27 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2069
2313
  }
2070
2314
  });
2071
2315
 
2072
- // Cleanup on app quit
2316
+ // Window cleanup also runs during renderer soft restart; native release belongs to app quit.
2073
2317
  webContents.on('destroyed', () => {
2318
+ windowDestroyed = true;
2319
+ if (disposing) return;
2074
2320
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
2075
- (async () => {
2321
+ const previousCleanup = windowCleanup;
2322
+ // Cancel before yielding so old timers cannot stop a replacement renderer's scan.
2323
+ const enumerations = Array.from(pendingEnumerations, cancel => cancel());
2324
+ windowCleanup = (async () => {
2325
+ if (previousCleanup) await previousCleanup;
2326
+ await Promise.allSettled(enumerations);
2327
+ if (disposing) return;
2076
2328
  const deviceIds = Array.from(connectedDevices.keys());
2077
2329
  for (const deviceId of deviceIds) {
2330
+ if (disposing) return;
2078
2331
  await unsubscribeNotifications(deviceId).catch(() => undefined);
2332
+ if (disposing) return;
2079
2333
  await disconnectDevice(deviceId).catch(() => undefined);
2080
2334
  }
2081
2335
 
2336
+ if (disposing) return;
2082
2337
  await stopScanning().catch(() => undefined);
2083
2338
  // persistentStateListener is process-lifetime, NOT per-window: this
2084
2339
  // destroy handler also fires on a renderer soft restart, and removing
@@ -2100,3 +2355,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2100
2355
  throw error;
2101
2356
  }
2102
2357
  }
2358
+
2359
+ /**
2360
+ * Terminal, bounded cleanup while the Node environment is still alive.
2361
+ * If Noble is shared with another transport, releaseNoble must queue the instance
2362
+ * and call stop() once after every transport has finished its cleanup.
2363
+ */
2364
+ export function disposeNobleBleSupport(
2365
+ releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
2366
+ ): Promise<void> {
2367
+ if (disposePromise) return disposePromise;
2368
+ disposing = true;
2369
+ removeIpcHandlers?.();
2370
+ const instance = noble;
2371
+ const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
2372
+ const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
2373
+ for (const cancel of pendingCancellations) cancel();
2374
+ for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
2375
+ for (const pending of connectingDevices.values()) {
2376
+ pending.cancel(
2377
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
2378
+ );
2379
+ }
2380
+ const connections = Array.from(nativeConnections);
2381
+ for (const connection of connections) {
2382
+ try {
2383
+ connection.cancel();
2384
+ } catch (error) {
2385
+ logger?.warn('[NobleBLE] Native connect cancellation failed', error);
2386
+ }
2387
+ }
2388
+ let timeout: ReturnType<typeof setTimeout> | undefined;
2389
+ disposePromise = (async () => {
2390
+ try {
2391
+ await Promise.race([
2392
+ Promise.allSettled([
2393
+ windowCleanup,
2394
+ ...connections.map(connection => connection.settled),
2395
+ stopScanning(),
2396
+ ...pendingPeripherals.map(peripheral =>
2397
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
2398
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
2399
+ timeoutBehavior: 'resolve',
2400
+ })
2401
+ ),
2402
+ ...Array.from(deviceIds, async id => {
2403
+ await unsubscribeNotifications(id).catch(() => undefined);
2404
+ await disconnectDevice(id).catch(() => undefined);
2405
+ }),
2406
+ ]),
2407
+ new Promise<void>(resolve => {
2408
+ timeout = setTimeout(() => {
2409
+ logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
2410
+ resolve();
2411
+ }, 3500);
2412
+ }),
2413
+ ]);
2414
+ } finally {
2415
+ clearTimeout(timeout);
2416
+ // Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
2417
+ nativeReleased = true;
2418
+ cleanupNobleListeners();
2419
+ if (instance && persistentStateListener) {
2420
+ instance.removeListener('stateChange', persistentStateListener);
2421
+ persistentStateListener = null;
2422
+ }
2423
+ for (const id of deviceIds) cleanupDevice(id);
2424
+ discoveredDevices.clear();
2425
+ directConnectCooldownUntil.clear();
2426
+ if (instance) releaseNoble(instance);
2427
+ logger?.info('[NobleBLE] Process dispose completed');
2428
+ }
2429
+ })();
2430
+ return disposePromise;
2431
+ }