@onekeyfe/hd-transport-electron 1.2.2-alpha.102 → 1.2.2-alpha.104

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.
@@ -55,13 +55,13 @@ function invokeNobleBleIpc(request) {
55
55
 
56
56
  function initNobleBleSupport(webContents) {
57
57
  return __awaiter(this, void 0, void 0, function* () {
58
- const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-3a1d131d.js'); });
58
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-263d4736.js'); });
59
59
  setupNobleBleHandlers(webContents);
60
60
  });
61
61
  }
62
62
  function disposeNobleBleSupport(releaseNoble) {
63
63
  return __awaiter(this, void 0, void 0, function* () {
64
- const handler = yield Promise.resolve().then(function () { return require('./noble-ble-handler-3a1d131d.js'); });
64
+ const handler = yield Promise.resolve().then(function () { return require('./noble-ble-handler-263d4736.js'); });
65
65
  yield handler.disposeNobleBleSupport(releaseNoble);
66
66
  });
67
67
  }
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ interface NobleModule {
18
18
  startScanning(serviceUUIDs: string[], allowDuplicates: boolean, callback?: (error?: Error) => void): void;
19
19
  stopScanning(callback?: () => void): void;
20
20
  stop(): void;
21
+ cancelConnect?(id: string): void;
21
22
  on(event: 'stateChange', listener: (state: string) => void): void;
22
23
  on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
23
24
  removeListener(event: 'stateChange', listener: (state: string) => void): void;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var index = require('./index-8e6acfc1.js');
5
+ var index = require('./index-985e2bf3.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-8e6acfc1.js');
3
+ var index = require('./index-985e2bf3.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -101,9 +101,21 @@ const NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS = 10000;
101
101
  let noble = null;
102
102
  let logger = null;
103
103
  let disposing = false;
104
+ let nativeReleased = false;
104
105
  let disposePromise;
106
+ let windowCleanup;
105
107
  let removeIpcHandlers;
106
108
  const pendingCancellations = new Set();
109
+ const pendingEnumerations = new Set();
110
+ const nativeConnections = new Set();
111
+ function trackNativeConnection(operation, cancel) {
112
+ const connection = {
113
+ cancel,
114
+ settled: operation.then(() => undefined, () => undefined),
115
+ };
116
+ nativeConnections.add(connection);
117
+ connection.settled = connection.settled.finally(() => nativeConnections.delete(connection));
118
+ }
107
119
  function assertBleActive() {
108
120
  if (disposing) {
109
121
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
@@ -182,9 +194,24 @@ function connectPeripheralWithCancellation(peripheral, deviceId, messagePrefix =
182
194
  (_a = connectingDevices
183
195
  .get(deviceId)) === null || _a === void 0 ? void 0 : _a.cancel(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleDeviceDisconnected, `Device ${deviceId} connect attempt was superseded`));
184
196
  connectingDevices.set(deviceId, pendingConnection);
185
- peripheral.connect(error => {
197
+ let completeNative;
198
+ trackNativeConnection(new Promise(resolve => {
199
+ completeNative = resolve;
200
+ }), () => {
201
+ var _a;
202
+ if (typeof peripheral.cancelConnect === 'function')
203
+ peripheral.cancelConnect();
204
+ else
205
+ (_a = noble === null || noble === void 0 ? void 0 : noble.cancelConnect) === null || _a === void 0 ? void 0 : _a.call(noble, deviceId);
206
+ });
207
+ const onConnect = (error) => {
208
+ if (nativeReleased) {
209
+ completeNative();
210
+ return;
211
+ }
186
212
  if (error) {
187
213
  settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
214
+ completeNative();
188
215
  return;
189
216
  }
190
217
  if (!settle(() => {
@@ -195,13 +222,27 @@ function connectPeripheralWithCancellation(peripheral, deviceId, messagePrefix =
195
222
  if (!activeConnection || activeConnection.peripheral !== peripheral) {
196
223
  try {
197
224
  peripheral.removeAllListeners('disconnect');
198
- peripheral.disconnect(() => undefined);
225
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
226
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
227
+ timeoutBehavior: 'resolve',
228
+ })
229
+ .catch(() => undefined)
230
+ .finally(completeNative);
231
+ return;
199
232
  }
200
233
  catch (_a) {
201
234
  }
202
235
  }
203
236
  }
204
- });
237
+ completeNative();
238
+ };
239
+ try {
240
+ peripheral.connect(onConnect);
241
+ }
242
+ catch (error) {
243
+ completeNative();
244
+ settle(() => reject(error));
245
+ }
205
246
  });
206
247
  }
207
248
  const pairedDevices = new Set();
@@ -833,7 +874,7 @@ function performTargetedScan(targetDeviceId, timeoutMs = NOBLE_BLE_TARGETED_SCAN
833
874
  });
834
875
  });
835
876
  }
836
- function enumerateDevices() {
877
+ function enumerateDevices(isWindowDestroyed) {
837
878
  return index.__awaiter(this, void 0, void 0, function* () {
838
879
  if (!noble) {
839
880
  yield initializeNoble();
@@ -842,6 +883,8 @@ function enumerateDevices() {
842
883
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
843
884
  }
844
885
  assertBleActive();
886
+ if (isWindowDestroyed())
887
+ return [];
845
888
  const nobleInstance = noble;
846
889
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Starting device enumeration');
847
890
  discoveredDevices.clear();
@@ -850,14 +893,20 @@ function enumerateDevices() {
850
893
  const devices = [];
851
894
  let settled = false;
852
895
  let intervalId;
853
- const cleanup = () => index.__awaiter(this, void 0, void 0, function* () {
854
- settled = true;
855
- pendingCancellations.delete(cancel);
856
- clearTimeout(timeoutId);
857
- if (intervalId)
858
- clearInterval(intervalId);
859
- yield waitForNobleScanStop(nobleInstance);
860
- });
896
+ let cleanupPromise;
897
+ const cleanup = () => {
898
+ if (!cleanupPromise) {
899
+ settled = true;
900
+ pendingCancellations.delete(cancel);
901
+ clearTimeout(timeoutId);
902
+ if (intervalId)
903
+ clearInterval(intervalId);
904
+ cleanupPromise = waitForNobleScanStop(nobleInstance).finally(() => {
905
+ pendingEnumerations.delete(cancel);
906
+ });
907
+ }
908
+ return cleanupPromise;
909
+ };
861
910
  const pushDevice = (peripheral, id) => {
862
911
  var _a;
863
912
  if (devices.some(d => d.id === id))
@@ -881,10 +930,9 @@ function enumerateDevices() {
881
930
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Scan completed, found devices:', devices.length);
882
931
  resolve(devices);
883
932
  }), DEVICE_SCAN_TIMEOUT);
884
- const cancel = () => {
885
- cleanup().then(() => resolve([]), reject);
886
- };
933
+ const cancel = () => cleanup().then(() => resolve([]), reject);
887
934
  pendingCancellations.add(cancel);
935
+ pendingEnumerations.add(cancel);
888
936
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Scanning for OneKey BLE devices');
889
937
  nobleInstance.startScanning([], true, (error) => index.__awaiter(this, void 0, void 0, function* () {
890
938
  if (settled)
@@ -903,7 +951,7 @@ function enumerateDevices() {
903
951
  }
904
952
  function stopScanning() {
905
953
  return index.__awaiter(this, void 0, void 0, function* () {
906
- if (!noble)
954
+ if (!noble || nativeReleased)
907
955
  return;
908
956
  const nobleInstance = noble;
909
957
  yield waitForNobleScanStop(nobleInstance);
@@ -1168,12 +1216,28 @@ function tryDirectConnectById(deviceId) {
1168
1216
  return undefined;
1169
1217
  let timer;
1170
1218
  let cancel;
1219
+ let abandoned = false;
1171
1220
  try {
1172
1221
  const directPromise = nobleInstance.connectAsync(deviceId);
1222
+ trackNativeConnection(directPromise.then((late) => index.__awaiter(this, void 0, void 0, function* () {
1223
+ if ((!abandoned && !disposing) || nativeReleased)
1224
+ return;
1225
+ const peripheral = late !== null && late !== void 0 ? late : discoveredDevices.get(deviceId);
1226
+ if ((peripheral === null || peripheral === void 0 ? void 0 : peripheral.state) === 'connected' && !connectedDevices.has(deviceId)) {
1227
+ peripheral.removeAllListeners('disconnect');
1228
+ yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1229
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1230
+ timeoutBehavior: 'resolve',
1231
+ });
1232
+ }
1233
+ })), () => { var _a; return (_a = nobleInstance.cancelConnect) === null || _a === void 0 ? void 0 : _a.call(nobleInstance, deviceId); });
1173
1234
  const raced = yield Promise.race([
1174
1235
  directPromise,
1175
1236
  new Promise(resolve => {
1176
- cancel = () => resolve('timeout');
1237
+ cancel = () => {
1238
+ abandoned = true;
1239
+ resolve('timeout');
1240
+ };
1177
1241
  pendingCancellations.add(cancel);
1178
1242
  timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
1179
1243
  }),
@@ -1183,17 +1247,6 @@ function tryDirectConnectById(deviceId) {
1183
1247
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1184
1248
  deviceId,
1185
1249
  });
1186
- directPromise
1187
- .then(late => {
1188
- const latePeripheral = late !== null && late !== void 0 ? late : discoveredDevices.get(deviceId);
1189
- if (latePeripheral &&
1190
- latePeripheral.state === 'connected' &&
1191
- !connectedDevices.has(deviceId)) {
1192
- latePeripheral.removeAllListeners('disconnect');
1193
- latePeripheral.disconnect(() => undefined);
1194
- }
1195
- })
1196
- .catch(() => undefined);
1197
1250
  return undefined;
1198
1251
  }
1199
1252
  const peripheral = raced !== null && raced !== void 0 ? raced : discoveredDevices.get(deviceId);
@@ -1333,6 +1386,8 @@ function connectDevice(deviceId, webContents) {
1333
1386
  function disconnectDevice(deviceId) {
1334
1387
  var _a;
1335
1388
  return index.__awaiter(this, void 0, void 0, function* () {
1389
+ if (nativeReleased)
1390
+ return;
1336
1391
  const pendingConnection = connectingDevices.get(deviceId);
1337
1392
  const peripheral = (_a = connectedDevices.get(deviceId)) !== null && _a !== void 0 ? _a : pendingConnection === null || pendingConnection === void 0 ? void 0 : pendingConnection.peripheral;
1338
1393
  if (!peripheral) {
@@ -1362,6 +1417,8 @@ function disconnectDevice(deviceId) {
1362
1417
  }
1363
1418
  function unsubscribeNotifications(deviceId) {
1364
1419
  return index.__awaiter(this, void 0, void 0, function* () {
1420
+ if (nativeReleased)
1421
+ return;
1365
1422
  const peripheral = connectedDevices.get(deviceId);
1366
1423
  const characteristics = deviceCharacteristics.get(deviceId);
1367
1424
  if (!peripheral || !characteristics) {
@@ -1469,6 +1526,7 @@ function subscribeNotifications(deviceId, callback) {
1469
1526
  function setupNobleBleHandlers(webContents) {
1470
1527
  if (disposing)
1471
1528
  return;
1529
+ let windowDestroyed = false;
1472
1530
  try {
1473
1531
  logger = require('electron-log');
1474
1532
  const { ipcMain } = require('electron');
@@ -1479,7 +1537,12 @@ function setupNobleBleHandlers(webContents) {
1479
1537
  ipcMain.removeHandler(channel);
1480
1538
  ipcMain.handle(channel, (...args) => index.__awaiter(this, void 0, void 0, function* () {
1481
1539
  try {
1540
+ if (windowCleanup)
1541
+ yield windowCleanup;
1482
1542
  assertBleActive();
1543
+ if (windowDestroyed) {
1544
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleDeviceDisconnected, 'BLE window destroyed');
1545
+ }
1483
1546
  return yield Promise.resolve(listener(...args));
1484
1547
  }
1485
1548
  catch (error) {
@@ -1490,7 +1553,7 @@ function setupNobleBleHandlers(webContents) {
1490
1553
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1491
1554
  handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, () => index.__awaiter(this, void 0, void 0, function* () {
1492
1555
  try {
1493
- const devices = yield enumerateDevices();
1556
+ const devices = yield enumerateDevices(() => windowDestroyed);
1494
1557
  safeLog(logger, 'debug', 'Enumeration completed', {
1495
1558
  count: devices.length,
1496
1559
  devices: devices.map(device => ({ id: device.id, name: device.name })),
@@ -1582,15 +1645,29 @@ function setupNobleBleHandlers(webContents) {
1582
1645
  }
1583
1646
  }));
1584
1647
  webContents.on('destroyed', () => {
1648
+ windowDestroyed = true;
1585
1649
  if (disposing)
1586
1650
  return;
1587
1651
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1588
- (() => index.__awaiter(this, void 0, void 0, function* () {
1652
+ const previousCleanup = windowCleanup;
1653
+ const enumerations = Array.from(pendingEnumerations, cancel => cancel());
1654
+ windowCleanup = (() => index.__awaiter(this, void 0, void 0, function* () {
1655
+ if (previousCleanup)
1656
+ yield previousCleanup;
1657
+ yield Promise.allSettled(enumerations);
1658
+ if (disposing)
1659
+ return;
1589
1660
  const deviceIds = Array.from(connectedDevices.keys());
1590
1661
  for (const deviceId of deviceIds) {
1662
+ if (disposing)
1663
+ return;
1591
1664
  yield unsubscribeNotifications(deviceId).catch(() => undefined);
1665
+ if (disposing)
1666
+ return;
1592
1667
  yield disconnectDevice(deviceId).catch(() => undefined);
1593
1668
  }
1669
+ if (disposing)
1670
+ return;
1594
1671
  yield stopScanning().catch(() => undefined);
1595
1672
  cleanupNobleListeners();
1596
1673
  discoveredDevices.clear();
@@ -1621,11 +1698,22 @@ function disposeNobleBleSupport(releaseNoble = instance => instance.stop()) {
1621
1698
  for (const pending of connectingDevices.values()) {
1622
1699
  pending.cancel(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down'));
1623
1700
  }
1701
+ const connections = Array.from(nativeConnections);
1702
+ for (const connection of connections) {
1703
+ try {
1704
+ connection.cancel();
1705
+ }
1706
+ catch (error) {
1707
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Native connect cancellation failed', error);
1708
+ }
1709
+ }
1624
1710
  let timeout;
1625
1711
  disposePromise = (() => index.__awaiter(this, void 0, void 0, function* () {
1626
1712
  try {
1627
1713
  yield Promise.race([
1628
1714
  Promise.allSettled([
1715
+ windowCleanup,
1716
+ ...connections.map(connection => connection.settled),
1629
1717
  stopScanning(),
1630
1718
  ...pendingPeripherals.map(peripheral => runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1631
1719
  timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
@@ -1646,6 +1734,7 @@ function disposeNobleBleSupport(releaseNoble = instance => instance.stop()) {
1646
1734
  }
1647
1735
  finally {
1648
1736
  clearTimeout(timeout);
1737
+ nativeReleased = true;
1649
1738
  cleanupNobleListeners();
1650
1739
  if (instance && persistentStateListener) {
1651
1740
  instance.removeListener('stateChange', persistentStateListener);
@@ -1 +1 @@
1
- {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAiB1F,KAAK,mBAAmB,GAAG,KAAK,GAAG;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,mBAAmB,EAAE,aAAa,SAAK,+CAiB3F;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,wBAAwB,CAgCvF;AAmJD,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAsxDD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CA+MpE;AAOD,wBAAgB,sBAAsB,CACpC,YAAY,GAAE,CAAC,QAAQ,EAAE;IAAE,IAAI,IAAI,IAAI,CAAA;CAAE,KAAK,IAAkC,GAC/E,OAAO,CAAC,IAAI,CAAC,CAqDf"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAiC1F,KAAK,mBAAmB,GAAG,KAAK,GAAG;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,mBAAmB,EAAE,aAAa,SAAK,+CAiB3F;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,wBAAwB,CAgCvF;AA+KD,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAkyDD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAkOpE;AAOD,wBAAgB,sBAAsB,CACpC,YAAY,GAAE,CAAC,QAAQ,EAAE;IAAE,IAAI,IAAI,IAAI,CAAA;CAAE,KAAK,IAAkC,GAC/E,OAAO,CAAC,IAAI,CAAC,CAiEf"}
@@ -15,6 +15,7 @@ export interface NobleModule {
15
15
  startScanning(serviceUUIDs: string[], allowDuplicates: boolean, callback?: (error?: Error) => void): void;
16
16
  stopScanning(callback?: () => void): void;
17
17
  stop(): void;
18
+ cancelConnect?(id: string): void;
18
19
  on(event: 'stateChange', listener: (state: string) => void): void;
19
20
  on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
20
21
  removeListener(event: 'stateChange', listener: (state: string) => void): void;
@@ -1 +1 @@
1
- {"version":3,"file":"noble-extended.d.ts","sourceRoot":"","sources":["../../src/types/noble-extended.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGnE,MAAM,WAAW,UAAW,SAAQ,oBAAoB;IACtD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,cAAc,CAAC;CACxB;AAGD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CACX,YAAY,EAAE,MAAM,EAAE,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GACjC,IAAI,CAAC;IACR,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC1C,IAAI,IAAI,IAAI,CAAC;IACb,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;IACxE,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC9E,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;CACrF;AAGD,MAAM,WAAW,MAAM;IACrB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC7C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC9C;AAGD,wBAAgB,OAAO,CACrB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,EACjC,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,GAAG,EAAE,GACb,IAAI,CAMN"}
1
+ {"version":3,"file":"noble-extended.d.ts","sourceRoot":"","sources":["../../src/types/noble-extended.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGnE,MAAM,WAAW,UAAW,SAAQ,oBAAoB;IACtD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,cAAc,CAAC;CACxB;AAGD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CACX,YAAY,EAAE,MAAM,EAAE,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GACjC,IAAI,CAAC;IACR,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC1C,IAAI,IAAI,IAAI,CAAC;IACb,aAAa,CAAC,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;IACxE,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC9E,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;CACrF;AAGD,MAAM,WAAW,MAAM;IACrB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC7C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC9C;AAGD,wBAAgB,OAAO,CACrB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,EACjC,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,GAAG,EAAE,GACb,IAAI,CAMN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-electron",
3
- "version": "1.2.2-alpha.102",
3
+ "version": "1.2.2-alpha.104",
4
4
  "author": "OneKey",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "electron-log": ">=4.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "@onekeyfe/hd-core": "1.2.2-alpha.102",
29
- "@onekeyfe/hd-shared": "1.2.2-alpha.102",
30
- "@onekeyfe/hd-transport": "1.2.2-alpha.102",
28
+ "@onekeyfe/hd-core": "1.2.2-alpha.104",
29
+ "@onekeyfe/hd-shared": "1.2.2-alpha.104",
30
+ "@onekeyfe/hd-transport": "1.2.2-alpha.104",
31
31
  "@stoprocent/noble": "2.3.16",
32
32
  "p-retry": "^4.6.2"
33
33
  },
@@ -36,5 +36,5 @@
36
36
  "electron": "^25.0.0",
37
37
  "typescript": "^5.3.3"
38
38
  },
39
- "gitHead": "abddc95e03d794d9c6fce7765d59de1f48a684d0"
39
+ "gitHead": "67835939d9f779ffd50cd8b482d8c5b53bd580bd"
40
40
  }
@@ -750,6 +750,10 @@ describe('Electron Noble BLE device discovery', () => {
750
750
  });
751
751
 
752
752
  describe('Noble BLE process shutdown', () => {
753
+ const flushCallbacks = () =>
754
+ new Promise<void>(resolve => {
755
+ setImmediate(resolve);
756
+ });
753
757
  afterEach(() => {
754
758
  jest.useRealTimers();
755
759
  jest.resetModules();
@@ -825,16 +829,20 @@ describe('Noble BLE process shutdown', () => {
825
829
  expect(jest.getTimerCount()).toBe(0);
826
830
  });
827
831
 
828
- test('disconnects an in-flight connection and rejects its late success', async () => {
832
+ test('cancels native connects and waits for disconnect before release', async () => {
829
833
  const { sdk, native, handlers } = await setup();
830
834
  await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
831
- let connectCallback: (() => void) | undefined;
835
+ let connectCallback: ((error?: Error) => void) | undefined;
836
+ let disconnectCallback: (() => void) | undefined;
832
837
  const peripheral = Object.assign(new EventEmitter(), {
833
838
  ...createPeripheral('pending-device', 'OneKey Pro'),
834
839
  connect: jest.fn(callback => {
835
840
  connectCallback = callback;
836
841
  }),
837
- disconnect: jest.fn(callback => callback?.()),
842
+ cancelConnect: jest.fn(() => connectCallback?.(new Error('connection canceled'))),
843
+ disconnect: jest.fn(callback => {
844
+ disconnectCallback = callback;
845
+ }),
838
846
  discoverServices: jest.fn(),
839
847
  });
840
848
  native.emit('discover', peripheral);
@@ -842,14 +850,70 @@ describe('Noble BLE process shutdown', () => {
842
850
  await Promise.resolve();
843
851
  await Promise.resolve();
844
852
  expect(peripheral.connect).toHaveBeenCalledTimes(1);
845
- await sdk.disposeNobleBleSupport();
853
+ const disposing = sdk.disposeNobleBleSupport();
854
+ await flushCallbacks();
855
+ expect(peripheral.cancelConnect).toHaveBeenCalledTimes(1);
846
856
  expect(peripheral.disconnect).toHaveBeenCalled();
857
+ expect(native.stop).not.toHaveBeenCalled();
858
+ disconnectCallback?.();
859
+ await disposing;
847
860
  expect(native.stop).toHaveBeenCalledTimes(1);
848
- connectCallback?.();
849
861
  expect(await connecting).toMatchObject({ success: false });
850
862
  expect(peripheral.discoverServices).not.toHaveBeenCalled();
851
863
  });
852
864
 
865
+ test.each(['peripheral', 'direct'])(
866
+ 'keeps late %s connects inert after the disposal timeout',
867
+ async route => {
868
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
869
+ const { sdk, native, handlers } = await setup();
870
+ await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
871
+ let finishConnect: () => void = () => undefined;
872
+ const peripheral = Object.assign(new EventEmitter(), {
873
+ ...createPeripheral('late-device', 'OneKey Pro'),
874
+ connect: jest.fn(callback => {
875
+ finishConnect = () => callback();
876
+ }),
877
+ cancelConnect: jest.fn(),
878
+ disconnect: jest.fn(callback => callback?.()),
879
+ discoverServices: jest.fn(),
880
+ });
881
+ const connectAsync = jest.fn(
882
+ () =>
883
+ new Promise(resolve => {
884
+ finishConnect = () => resolve(peripheral);
885
+ })
886
+ );
887
+ const cancelConnect = jest.fn();
888
+ if (route === 'direct') Object.assign(native, { connectAsync, cancelConnect });
889
+ else native.emit('discover', peripheral);
890
+ const connecting = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, peripheral.id);
891
+ await flushCallbacks();
892
+ if (route === 'direct') {
893
+ jest.advanceTimersByTime(1500);
894
+ await flushCallbacks();
895
+ }
896
+ expect(route === 'direct' ? connectAsync : peripheral.connect).toHaveBeenCalledTimes(1);
897
+ const disposing = sdk.disposeNobleBleSupport();
898
+ await flushCallbacks();
899
+ expect(route === 'direct' ? cancelConnect : peripheral.cancelConnect).toHaveBeenCalledTimes(
900
+ 1
901
+ );
902
+ expect(native.stop).not.toHaveBeenCalled();
903
+ jest.advanceTimersByTime(3500);
904
+ await disposing;
905
+ const disconnects = peripheral.disconnect.mock.calls.length;
906
+ peripheral.state = 'connected';
907
+ finishConnect();
908
+ await flushCallbacks();
909
+ expect(peripheral.disconnect).toHaveBeenCalledTimes(disconnects);
910
+ expect(peripheral.discoverServices).not.toHaveBeenCalled();
911
+ expect(native.stop).toHaveBeenCalledTimes(1);
912
+ expect(await connecting).toMatchObject({ success: false });
913
+ expect(jest.getTimerCount()).toBe(0);
914
+ }
915
+ );
916
+
853
917
  test('allows a host to defer shared native release until both transports are idle', async () => {
854
918
  const { sdk, native, handlers } = await setup();
855
919
  await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
@@ -859,16 +923,96 @@ describe('Noble BLE process shutdown', () => {
859
923
  expect(native.stop).not.toHaveBeenCalled();
860
924
  });
861
925
 
862
- test('window destruction preserves the native manager for a soft restart', async () => {
926
+ test('replacement window scanning waits for old cleanup and preserves native', async () => {
927
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
863
928
  const { sdk, native, handlers, window } = await setup();
864
929
  await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
930
+ let finishOldCleanup: (() => void) | undefined;
931
+ native.stopScanning.mockImplementationOnce(callback => {
932
+ finishOldCleanup = callback;
933
+ });
865
934
  window.emit('destroyed');
866
- await Promise.resolve();
867
- await Promise.resolve();
868
935
  sdk.setupNobleBleHandlers(new EventEmitter() as unknown as WebContents);
869
- await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
936
+ const scan = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE)?.({});
937
+ await flushCallbacks();
938
+ expect(native.startScanning).not.toHaveBeenCalled();
939
+ finishOldCleanup?.();
940
+ await flushCallbacks();
941
+ expect(native.startScanning).toHaveBeenCalledTimes(1);
942
+ expect(native.listenerCount('discover')).toBe(1);
943
+ native.emit('discover', createPeripheral('replacement-device', 'OneKey Pro'));
944
+ jest.advanceTimersByTime(5000);
945
+ expect(await scan).toEqual([expect.objectContaining({ id: 'replacement-device' })]);
870
946
  expect(native.stop).not.toHaveBeenCalled();
871
947
  await sdk.disposeNobleBleSupport();
872
948
  expect(native.stop).toHaveBeenCalledTimes(1);
873
949
  });
950
+
951
+ test.each(['started', 'late-start-callback'])(
952
+ 'retires an old enumeration before a shared native scan resumes: %s',
953
+ async scanState => {
954
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
955
+ const { sdk, native, handlers, window } = await setup();
956
+ let lateStart: (() => void) | undefined;
957
+ if (scanState === 'late-start-callback') {
958
+ native.startScanning.mockImplementationOnce((_uuids, _duplicates, callback) => {
959
+ lateStart = callback;
960
+ });
961
+ }
962
+ let oldScanSettled = false;
963
+ const oldScan = Promise.resolve(
964
+ handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE)?.({})
965
+ ).then(result => {
966
+ oldScanSettled = true;
967
+ return result;
968
+ });
969
+ await flushCallbacks();
970
+ jest.advanceTimersByTime(1000);
971
+
972
+ let finishOldScan: (() => void) | undefined;
973
+ native.stopScanning.mockImplementationOnce(callback => {
974
+ finishOldScan = callback;
975
+ });
976
+ window.emit('destroyed');
977
+ sdk.setupNobleBleHandlers(new EventEmitter() as unknown as WebContents);
978
+ const replacementReady = handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
979
+ let replacementSettled = false;
980
+ Promise.resolve(replacementReady).then(() => {
981
+ replacementSettled = true;
982
+ });
983
+ await flushCallbacks();
984
+ expect(replacementSettled).toBe(false);
985
+ finishOldScan?.();
986
+ await replacementReady;
987
+ await flushCallbacks();
988
+ expect(oldScanSettled).toBe(true);
989
+ expect(await oldScan).toEqual([]);
990
+
991
+ // Another transport can now scan on the same process-wide Noble instance.
992
+ native.startScanning([], true, () => undefined);
993
+ const stopCount = native.stopScanning.mock.calls.length;
994
+ lateStart?.();
995
+ jest.advanceTimersByTime(5000);
996
+ await flushCallbacks();
997
+ expect(native.stopScanning).toHaveBeenCalledTimes(stopCount);
998
+ expect(jest.getTimerCount()).toBe(0);
999
+ expect(native.stop).not.toHaveBeenCalled();
1000
+ await sdk.disposeNobleBleSupport();
1001
+ }
1002
+ );
1003
+
1004
+ test('does not start an old window enumeration after Bluetooth becomes ready', async () => {
1005
+ const { sdk, native, handlers, window } = await setup('unknown');
1006
+ const oldScan = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE)?.({});
1007
+ await flushCallbacks();
1008
+ window.emit('destroyed');
1009
+ sdk.setupNobleBleHandlers(new EventEmitter() as unknown as WebContents);
1010
+ await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
1011
+
1012
+ native.state = 'poweredOn';
1013
+ native.emit('stateChange', 'poweredOn');
1014
+ expect(await oldScan).toEqual([]);
1015
+ expect(native.startScanning).not.toHaveBeenCalled();
1016
+ await sdk.disposeNobleBleSupport();
1017
+ });
874
1018
  });
@@ -41,9 +41,25 @@ import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './type
41
41
  let noble: NobleModule | null = null;
42
42
  let logger: Logger | null = null;
43
43
  let disposing = false;
44
+ let nativeReleased = false;
44
45
  let disposePromise: Promise<void> | undefined;
46
+ let windowCleanup: Promise<void> | undefined;
45
47
  let removeIpcHandlers: (() => void) | undefined;
46
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
+ }
47
63
 
48
64
  function assertBleActive(): void {
49
65
  if (disposing) {
@@ -170,9 +186,24 @@ function connectPeripheralWithCancellation(
170
186
  );
171
187
  connectingDevices.set(deviceId, pendingConnection);
172
188
 
173
- peripheral.connect(error => {
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
+ }
174
204
  if (error) {
175
205
  settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
206
+ completeNative();
176
207
  return;
177
208
  }
178
209
 
@@ -186,13 +217,26 @@ function connectPeripheralWithCancellation(
186
217
  if (!activeConnection || activeConnection.peripheral !== peripheral) {
187
218
  try {
188
219
  peripheral.removeAllListeners('disconnect');
189
- peripheral.disconnect(() => undefined);
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;
190
227
  } catch {
191
228
  // Best-effort cleanup for a stale native callback.
192
229
  }
193
230
  }
194
231
  }
195
- });
232
+ completeNative();
233
+ };
234
+ try {
235
+ peripheral.connect(onConnect);
236
+ } catch (error) {
237
+ completeNative();
238
+ settle(() => reject(error));
239
+ }
196
240
  });
197
241
  }
198
242
  const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
@@ -1107,7 +1151,7 @@ async function performTargetedScan(
1107
1151
  }
1108
1152
 
1109
1153
  // Enumerate devices
1110
- async function enumerateDevices(): Promise<DeviceInfo[]> {
1154
+ async function enumerateDevices(isWindowDestroyed: () => boolean): Promise<DeviceInfo[]> {
1111
1155
  if (!noble) {
1112
1156
  await initializeNoble();
1113
1157
  }
@@ -1117,6 +1161,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1117
1161
  }
1118
1162
 
1119
1163
  assertBleActive();
1164
+ if (isWindowDestroyed()) return [];
1120
1165
  // Capture noble reference for use in closures (TypeScript narrowing)
1121
1166
  const nobleInstance = noble;
1122
1167
 
@@ -1133,16 +1178,22 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1133
1178
  const devices: DeviceInfo[] = [];
1134
1179
  let settled = false;
1135
1180
  let intervalId: ReturnType<typeof setInterval> | undefined;
1181
+ let cleanupPromise: Promise<void> | undefined;
1136
1182
 
1137
1183
  // Cleanup function: clears timers and waits until Noble confirms scanning
1138
1184
  // has stopped. Resolving enumerate before this callback creates a race with
1139
1185
  // an immediately-following connection attempt.
1140
- const cleanup = async () => {
1141
- settled = true;
1142
- pendingCancellations.delete(cancel);
1143
- clearTimeout(timeoutId);
1144
- if (intervalId) clearInterval(intervalId);
1145
- 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;
1146
1197
  };
1147
1198
 
1148
1199
  // Collect discovered devices into the devices array
@@ -1171,10 +1222,9 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1171
1222
  resolve(devices);
1172
1223
  }, DEVICE_SCAN_TIMEOUT);
1173
1224
 
1174
- const cancel = () => {
1175
- cleanup().then(() => resolve([]), reject);
1176
- };
1225
+ const cancel = () => cleanup().then(() => resolve([]), reject);
1177
1226
  pendingCancellations.add(cancel);
1227
+ pendingEnumerations.add(cancel);
1178
1228
 
1179
1229
  // Start scanning without a service UUID filter so Pro2 advertisements with
1180
1230
  // short vendor UUIDs can be found. Repeated advertisements are required when
@@ -1199,7 +1249,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1199
1249
 
1200
1250
  // Stop scanning
1201
1251
  async function stopScanning(): Promise<void> {
1202
- if (!noble) return;
1252
+ if (!noble || nativeReleased) return;
1203
1253
  const nobleInstance = noble;
1204
1254
  await waitForNobleScanStop(nobleInstance);
1205
1255
  logger?.info('[NobleBLE] Scanning stopped');
@@ -1651,13 +1701,31 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1651
1701
 
1652
1702
  let timer: ReturnType<typeof setTimeout> | undefined;
1653
1703
  let cancel: (() => void) | undefined;
1704
+ let abandoned = false;
1654
1705
  try {
1655
1706
  // The late-orphan guard must attach to THIS pending connect.
1656
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
+ );
1657
1722
  const raced = await Promise.race([
1658
1723
  directPromise,
1659
1724
  new Promise<'timeout'>(resolve => {
1660
- cancel = () => resolve('timeout');
1725
+ cancel = () => {
1726
+ abandoned = true;
1727
+ resolve('timeout');
1728
+ };
1661
1729
  pendingCancellations.add(cancel);
1662
1730
  timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
1663
1731
  }),
@@ -1667,20 +1735,6 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1667
1735
  logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1668
1736
  deviceId,
1669
1737
  });
1670
- // Promise.race times out the caller only; a late success orphans the link.
1671
- directPromise
1672
- .then(late => {
1673
- const latePeripheral = late ?? discoveredDevices.get(deviceId);
1674
- if (
1675
- latePeripheral &&
1676
- latePeripheral.state === 'connected' &&
1677
- !connectedDevices.has(deviceId)
1678
- ) {
1679
- latePeripheral.removeAllListeners('disconnect');
1680
- latePeripheral.disconnect(() => undefined);
1681
- }
1682
- })
1683
- .catch(() => undefined);
1684
1738
  return undefined;
1685
1739
  }
1686
1740
  // Backends emit `discover` as a side effect, so the cache may hold it.
@@ -1877,6 +1931,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1877
1931
 
1878
1932
  // Disconnect device
1879
1933
  async function disconnectDevice(deviceId: string): Promise<void> {
1934
+ if (nativeReleased) return;
1880
1935
  const pendingConnection = connectingDevices.get(deviceId);
1881
1936
  const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
1882
1937
  if (!peripheral) {
@@ -1917,6 +1972,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1917
1972
 
1918
1973
  // Unsubscribe from notifications
1919
1974
  async function unsubscribeNotifications(deviceId: string): Promise<void> {
1975
+ if (nativeReleased) return;
1920
1976
  const peripheral = connectedDevices.get(deviceId);
1921
1977
  const characteristics = deviceCharacteristics.get(deviceId);
1922
1978
 
@@ -2074,6 +2130,7 @@ async function subscribeNotifications(
2074
2130
  // Setup IPC handlers
2075
2131
  export function setupNobleBleHandlers(webContents: WebContents): void {
2076
2132
  if (disposing) return;
2133
+ let windowDestroyed = false;
2077
2134
  try {
2078
2135
  // @ts-ignore – electron-log is only available at runtime
2079
2136
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
@@ -2092,7 +2149,15 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2092
2149
  ipcMain.removeHandler(channel);
2093
2150
  ipcMain.handle(channel, async (...args) => {
2094
2151
  try {
2152
+ // A replacement renderer must not race the old renderer's shared-state cleanup.
2153
+ if (windowCleanup) await windowCleanup;
2095
2154
  assertBleActive();
2155
+ if (windowDestroyed) {
2156
+ throw ERRORS.TypedError(
2157
+ HardwareErrorCode.BleDeviceDisconnected,
2158
+ 'BLE window destroyed'
2159
+ );
2160
+ }
2096
2161
  return await Promise.resolve(listener(...args));
2097
2162
  } catch (error) {
2098
2163
  return createNobleBleIpcErrorResponse(error);
@@ -2105,7 +2170,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2105
2170
  // Handle enumerate request
2106
2171
  handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
2107
2172
  try {
2108
- const devices = await enumerateDevices();
2173
+ const devices = await enumerateDevices(() => windowDestroyed);
2109
2174
  safeLog(logger, 'debug', 'Enumeration completed', {
2110
2175
  count: devices.length,
2111
2176
  devices: devices.map(device => ({ id: device.id, name: device.name })),
@@ -2250,15 +2315,25 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2250
2315
 
2251
2316
  // Window cleanup also runs during renderer soft restart; native release belongs to app quit.
2252
2317
  webContents.on('destroyed', () => {
2318
+ windowDestroyed = true;
2253
2319
  if (disposing) return;
2254
2320
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
2255
- (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;
2256
2328
  const deviceIds = Array.from(connectedDevices.keys());
2257
2329
  for (const deviceId of deviceIds) {
2330
+ if (disposing) return;
2258
2331
  await unsubscribeNotifications(deviceId).catch(() => undefined);
2332
+ if (disposing) return;
2259
2333
  await disconnectDevice(deviceId).catch(() => undefined);
2260
2334
  }
2261
2335
 
2336
+ if (disposing) return;
2262
2337
  await stopScanning().catch(() => undefined);
2263
2338
  // persistentStateListener is process-lifetime, NOT per-window: this
2264
2339
  // destroy handler also fires on a renderer soft restart, and removing
@@ -2302,11 +2377,21 @@ export function disposeNobleBleSupport(
2302
2377
  ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
2303
2378
  );
2304
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
+ }
2305
2388
  let timeout: ReturnType<typeof setTimeout> | undefined;
2306
2389
  disposePromise = (async () => {
2307
2390
  try {
2308
2391
  await Promise.race([
2309
2392
  Promise.allSettled([
2393
+ windowCleanup,
2394
+ ...connections.map(connection => connection.settled),
2310
2395
  stopScanning(),
2311
2396
  ...pendingPeripherals.map(peripheral =>
2312
2397
  runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
@@ -2328,6 +2413,8 @@ export function disposeNobleBleSupport(
2328
2413
  ]);
2329
2414
  } finally {
2330
2415
  clearTimeout(timeout);
2416
+ // Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
2417
+ nativeReleased = true;
2331
2418
  cleanupNobleListeners();
2332
2419
  if (instance && persistentStateListener) {
2333
2420
  instance.removeListener('stateChange', persistentStateListener);
@@ -30,6 +30,7 @@ export interface NobleModule {
30
30
  ): void;
31
31
  stopScanning(callback?: () => void): void;
32
32
  stop(): void;
33
+ cancelConnect?(id: string): void;
33
34
  on(event: 'stateChange', listener: (state: string) => void): void;
34
35
  on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
35
36
  removeListener(event: 'stateChange', listener: (state: string) => void): void;