@onekeyfe/hd-transport-electron 1.2.0-alpha.73 → 1.2.0-alpha.75

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.
@@ -32,7 +32,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
32
32
 
33
33
  function initNobleBleSupport(webContents) {
34
34
  return __awaiter(this, void 0, void 0, function* () {
35
- const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-3bd9cd3e.js'); });
35
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-ce551d9a.js'); });
36
36
  setupNobleBleHandlers(webContents);
37
37
  });
38
38
  }
package/dist/index.d.ts CHANGED
@@ -45,6 +45,7 @@ interface NobleBleAPI {
45
45
  mtu?: number;
46
46
  } | null>;
47
47
  connect: (uuid: string) => Promise<void>;
48
+ release?: (uuid: string, keepSession?: boolean) => Promise<void>;
48
49
  disconnect: (uuid: string) => Promise<void>;
49
50
  subscribe: (uuid: string) => Promise<void>;
50
51
  unsubscribe: (uuid: string) => Promise<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-4f7dc0a5.js');
5
+ var index = require('./index-6da32f94.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-4f7dc0a5.js');
3
+ var index = require('./index-6da32f94.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -116,6 +116,8 @@ const DEVICE_SCAN_TIMEOUT = 5000;
116
116
  const DEVICE_CHECK_INTERVAL = 500;
117
117
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
118
118
  const BLE_CLEANUP_TIMEOUT = 250;
119
+ const BLE_IDLE_DISCONNECT_MS = 3 * 60000;
120
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60000;
119
121
  const BLE_PACKET_SIZE_FALLBACK = 192;
120
122
  const BLE_PACKET_SIZE_MAXIMUM = 244;
121
123
  const DEFAULT_WRITE_PACING_DELAY_MS = 5;
@@ -296,6 +298,62 @@ function initializeNoble() {
296
298
  }
297
299
  });
298
300
  }
301
+ const idleDisconnectTimers = new Map();
302
+ const idleDisconnectInFlight = new Map();
303
+ function clearIdleDisconnect(deviceId) {
304
+ const timer = idleDisconnectTimers.get(deviceId);
305
+ if (timer) {
306
+ clearTimeout(timer);
307
+ idleDisconnectTimers.delete(deviceId);
308
+ }
309
+ }
310
+ function awaitIdleDisconnect(deviceId) {
311
+ return index.__awaiter(this, void 0, void 0, function* () {
312
+ const pending = idleDisconnectInFlight.get(deviceId);
313
+ if (pending)
314
+ yield pending.catch(() => undefined);
315
+ });
316
+ }
317
+ function broadcastToAllWebContents(channel, payload) {
318
+ try {
319
+ const { webContents: electronWebContents } = require('electron');
320
+ for (const wc of electronWebContents.getAllWebContents()) {
321
+ if (!wc.isDestroyed())
322
+ wc.send(channel, payload);
323
+ }
324
+ }
325
+ catch (error) {
326
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
327
+ }
328
+ }
329
+ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle') {
330
+ clearIdleDisconnect(deviceId);
331
+ idleDisconnectTimers.set(deviceId, setTimeout(() => {
332
+ var _a;
333
+ idleDisconnectTimers.delete(deviceId);
334
+ if (!connectedDevices.has(deviceId))
335
+ return;
336
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
337
+ const peripheral = connectedDevices.get(deviceId);
338
+ const deviceName = ((_a = peripheral === null || peripheral === void 0 ? void 0 : peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
339
+ const pending = disconnectDevice(deviceId)
340
+ .then(() => {
341
+ broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
342
+ id: deviceId,
343
+ name: deviceName,
344
+ });
345
+ })
346
+ .catch(error => {
347
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
348
+ })
349
+ .finally(() => {
350
+ if (idleDisconnectInFlight.get(deviceId) === pending) {
351
+ idleDisconnectInFlight.delete(deviceId);
352
+ }
353
+ });
354
+ idleDisconnectInFlight.set(deviceId, pending);
355
+ }, ms));
356
+ }
299
357
  function cleanupDevice(deviceId, webContents, options = {}) {
300
358
  var _a;
301
359
  const { cleanupConnection = true, cleanupDiscoveredCache = false, sendDisconnectEvent = false, cancelOperations = true, reason = 'unknown', } = options;
@@ -307,6 +365,7 @@ function cleanupDevice(deviceId, webContents, options = {}) {
307
365
  sendDisconnectEvent,
308
366
  cancelOperations,
309
367
  });
368
+ clearIdleDisconnect(deviceId);
310
369
  const peripheral = connectedDevices.get(deviceId);
311
370
  const deviceName = ((_a = peripheral === null || peripheral === void 0 ? void 0 : peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
312
371
  if (cleanupConnection) {
@@ -330,8 +389,8 @@ function cleanupDevice(deviceId, webContents, options = {}) {
330
389
  if (cleanupDiscoveredCache) {
331
390
  discoveredDevices.delete(deviceId);
332
391
  }
333
- if (sendDisconnectEvent && webContents) {
334
- webContents.send(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
392
+ if (sendDisconnectEvent) {
393
+ broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
335
394
  id: deviceId,
336
395
  name: deviceName,
337
396
  });
@@ -373,6 +432,8 @@ function setupMtuListener(peripheral, deviceId, webContents) {
373
432
  const listener = (mtu) => {
374
433
  if (!Number.isFinite(mtu) || mtu <= 0)
375
434
  return;
435
+ if (webContents.isDestroyed())
436
+ return;
376
437
  webContents.send(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
377
438
  id: deviceId,
378
439
  mtu,
@@ -479,6 +540,7 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
479
540
  if (!peripheral || !characteristics) {
480
541
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleCharacteristicNotFound, `Device ${deviceId} not connected or characteristics not available`);
481
542
  }
543
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
482
544
  const toBuffer = Buffer.from(hexData, 'hex');
483
545
  const doGetWriteCharacteristic = () => { var _a; return (_a = deviceCharacteristics.get(deviceId)) === null || _a === void 0 ? void 0 : _a.write; };
484
546
  const packetCapacity = resolveBlePacketCapacity(peripheral.mtu, BLE_PACKET_SIZE_MAXIMUM, BLE_PACKET_SIZE_FALLBACK);
@@ -636,21 +698,21 @@ function enumerateDevices() {
636
698
  clearInterval(intervalId);
637
699
  yield waitForNobleScanStop(nobleInstance);
638
700
  });
639
- const checkDevices = () => {
640
- discoveredDevices.forEach((peripheral, id) => {
641
- var _a;
642
- const existingDevice = devices.find(d => d.id === id);
643
- if (!existingDevice) {
644
- const deviceName = ((_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
645
- devices.push({
646
- commType: 'electron-ble',
647
- id,
648
- name: deviceName,
649
- state: peripheral.state || 'disconnected',
650
- });
651
- }
701
+ const pushDevice = (peripheral, id) => {
702
+ var _a;
703
+ if (devices.some(d => d.id === id))
704
+ return;
705
+ devices.push({
706
+ commType: 'electron-ble',
707
+ id,
708
+ name: ((_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device',
709
+ state: peripheral.state || 'disconnected',
652
710
  });
653
711
  };
712
+ const checkDevices = () => {
713
+ discoveredDevices.forEach(pushDevice);
714
+ connectedDevices.forEach(pushDevice);
715
+ };
654
716
  const timeoutId = setTimeout(() => index.__awaiter(this, void 0, void 0, function* () {
655
717
  checkDevices();
656
718
  yield cleanup();
@@ -923,7 +985,62 @@ function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
923
985
  }
924
986
  });
925
987
  }
988
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
989
+ const DIRECT_CONNECT_COOLDOWN_MS = 15000;
990
+ const directConnectCooldownUntil = new Map();
991
+ function tryDirectConnectById(deviceId) {
992
+ var _a;
993
+ return index.__awaiter(this, void 0, void 0, function* () {
994
+ const nobleInstance = noble;
995
+ if (!(nobleInstance === null || nobleInstance === void 0 ? void 0 : nobleInstance.connectAsync))
996
+ return undefined;
997
+ const cooldownUntil = (_a = directConnectCooldownUntil.get(deviceId)) !== null && _a !== void 0 ? _a : 0;
998
+ if (Date.now() < cooldownUntil)
999
+ return undefined;
1000
+ try {
1001
+ const directPromise = nobleInstance.connectAsync(deviceId);
1002
+ const raced = yield Promise.race([
1003
+ directPromise,
1004
+ new Promise(resolve => {
1005
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1006
+ }),
1007
+ ]);
1008
+ if (raced === 'timeout') {
1009
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1010
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1011
+ deviceId,
1012
+ });
1013
+ directPromise
1014
+ .then(late => {
1015
+ const latePeripheral = late !== null && late !== void 0 ? late : discoveredDevices.get(deviceId);
1016
+ if (latePeripheral &&
1017
+ latePeripheral.state === 'connected' &&
1018
+ !connectedDevices.has(deviceId)) {
1019
+ latePeripheral.removeAllListeners('disconnect');
1020
+ latePeripheral.disconnect(() => undefined);
1021
+ }
1022
+ })
1023
+ .catch(() => undefined);
1024
+ return undefined;
1025
+ }
1026
+ const peripheral = raced !== null && raced !== void 0 ? raced : discoveredDevices.get(deviceId);
1027
+ if (!peripheral || peripheral.state !== 'connected')
1028
+ return undefined;
1029
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
1030
+ return peripheral;
1031
+ }
1032
+ catch (error) {
1033
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1034
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
1035
+ deviceId,
1036
+ error: String(error),
1037
+ });
1038
+ return undefined;
1039
+ }
1040
+ });
1041
+ }
926
1042
  function connectDevice(deviceId, webContents) {
1043
+ var _a;
927
1044
  return index.__awaiter(this, void 0, void 0, function* () {
928
1045
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
929
1046
  deviceId,
@@ -933,7 +1050,7 @@ function connectDevice(deviceId, webContents) {
933
1050
  totalDiscovered: discoveredDevices.size,
934
1051
  totalConnected: connectedDevices.size,
935
1052
  });
936
- let peripheral = discoveredDevices.get(deviceId);
1053
+ let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
937
1054
  if (!peripheral) {
938
1055
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
939
1056
  if (!noble) {
@@ -942,6 +1059,12 @@ function connectDevice(deviceId, webContents) {
942
1059
  if (!noble) {
943
1060
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
944
1061
  }
1062
+ peripheral = yield tryDirectConnectById(deviceId);
1063
+ if (peripheral) {
1064
+ discoveredDevices.set(deviceId, peripheral);
1065
+ }
1066
+ }
1067
+ if (!peripheral) {
945
1068
  try {
946
1069
  const foundPeripheral = yield performTargetedScan(deviceId);
947
1070
  if (!foundPeripheral) {
@@ -962,8 +1085,8 @@ function connectDevice(deviceId, webContents) {
962
1085
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device already connected, skipping connection step');
963
1086
  if (!connectedDevices.has(deviceId)) {
964
1087
  connectedDevices.set(deviceId, peripheral);
965
- setupDisconnectListener(peripheral, deviceId, webContents);
966
1088
  }
1089
+ setupDisconnectListener(peripheral, deviceId, webContents);
967
1090
  if (deviceCharacteristics.has(deviceId)) {
968
1091
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device characteristics already available');
969
1092
  const ongoingOperation = subscriptionOperations.get(deviceId);
@@ -985,9 +1108,16 @@ function connectDevice(deviceId, webContents) {
985
1108
  notificationCallbacks.delete(deviceId);
986
1109
  subscribedDevices.delete(deviceId);
987
1110
  }
988
- const characteristics = yield setupConnectionAndDiscoverServices(peripheral, deviceId, webContents);
989
- deviceCharacteristics.set(deviceId, characteristics);
990
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device ready for communication:', deviceId);
1111
+ try {
1112
+ const characteristics = yield setupConnectionAndDiscoverServices(peripheral, deviceId, webContents);
1113
+ deviceCharacteristics.set(deviceId, characteristics);
1114
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device ready for communication:', deviceId);
1115
+ }
1116
+ catch (setupError) {
1117
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
1118
+ yield disconnectDevice(deviceId).catch(() => undefined);
1119
+ throw setupError;
1120
+ }
991
1121
  return;
992
1122
  }
993
1123
  return new Promise((resolve, reject) => {
@@ -1152,8 +1282,12 @@ function setupNobleBleHandlers(webContents) {
1152
1282
  try {
1153
1283
  logger = require('electron-log');
1154
1284
  const { ipcMain } = require('electron');
1285
+ const handle = (channel, listener) => {
1286
+ ipcMain.removeHandler(channel);
1287
+ ipcMain.handle(channel, listener);
1288
+ };
1155
1289
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1156
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, () => index.__awaiter(this, void 0, void 0, function* () {
1290
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, () => index.__awaiter(this, void 0, void 0, function* () {
1157
1291
  try {
1158
1292
  const devices = yield enumerateDevices();
1159
1293
  safeLog(logger, 'debug', 'Enumeration completed', {
@@ -1167,34 +1301,54 @@ function setupNobleBleHandlers(webContents) {
1167
1301
  throw error;
1168
1302
  }
1169
1303
  }));
1170
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, () => index.__awaiter(this, void 0, void 0, function* () {
1304
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, () => index.__awaiter(this, void 0, void 0, function* () {
1171
1305
  yield stopScanning();
1172
1306
  }));
1173
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE, (_event, deviceId) => getDevice(deviceId));
1174
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1307
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE, (_event, deviceId) => getDevice(deviceId));
1308
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1175
1309
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] IPC CONNECT request received:', {
1176
1310
  deviceId,
1177
1311
  hasPeripheral: connectedDevices.has(deviceId),
1178
1312
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1179
1313
  totalConnectedDevices: connectedDevices.size,
1180
1314
  });
1181
- yield connectDevice(deviceId, webContents);
1315
+ clearIdleDisconnect(deviceId);
1316
+ yield awaitIdleDisconnect(deviceId);
1317
+ try {
1318
+ yield connectDevice(deviceId, webContents);
1319
+ }
1320
+ finally {
1321
+ if (connectedDevices.has(deviceId)) {
1322
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1323
+ }
1324
+ }
1182
1325
  }));
1183
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1326
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE, (_event, deviceId, keepSession) => {
1327
+ if (!connectedDevices.has(deviceId))
1328
+ return;
1329
+ if (keepSession) {
1330
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1331
+ return;
1332
+ }
1333
+ armIdleDisconnect(deviceId);
1334
+ });
1335
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1184
1336
  yield disconnectDevice(deviceId);
1185
1337
  }));
1186
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_WRITE, (_event, deviceId, hexData, options) => index.__awaiter(this, void 0, void 0, function* () {
1338
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_WRITE, (_event, deviceId, hexData, options) => index.__awaiter(this, void 0, void 0, function* () {
1187
1339
  yield transmitHexDataToDevice(deviceId, hexData, options);
1188
1340
  }));
1189
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1341
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1190
1342
  yield subscribeNotifications(deviceId, (data) => {
1191
1343
  webContents.send(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1192
1344
  });
1345
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1193
1346
  }));
1194
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1347
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1195
1348
  yield unsubscribeNotifications(deviceId);
1349
+ armIdleDisconnect(deviceId);
1196
1350
  }));
1197
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, () => index.__awaiter(this, void 0, void 0, function* () {
1351
+ handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, () => index.__awaiter(this, void 0, void 0, function* () {
1198
1352
  const deviceIds = Array.from(connectedDevices.keys());
1199
1353
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Cancel pairing invoked', {
1200
1354
  platform: process.platform,
@@ -1210,7 +1364,7 @@ function setupNobleBleHandlers(webContents) {
1210
1364
  }
1211
1365
  }
1212
1366
  }));
1213
- ipcMain.handle(hdShared.EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, () => index.__awaiter(this, void 0, void 0, function* () {
1367
+ handle(hdShared.EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, () => index.__awaiter(this, void 0, void 0, function* () {
1214
1368
  try {
1215
1369
  const bluetoothStatus = yield checkBluetoothAvailability();
1216
1370
  safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
@@ -1 +1 @@
1
- {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,UAAU,CAAC;AAEhE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAsEhE,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAw7CD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CA8JpE"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AA0EhE,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AA8lDD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAgMpE"}
@@ -12,6 +12,7 @@ export interface NobleBleAPI {
12
12
  mtu?: number;
13
13
  } | null>;
14
14
  connect: (uuid: string) => Promise<void>;
15
+ release?: (uuid: string, keepSession?: boolean) => Promise<void>;
15
16
  disconnect: (uuid: string) => Promise<void>;
16
17
  subscribe: (uuid: string) => Promise<void>;
17
18
  unsubscribe: (uuid: string) => Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"desktop-api.d.ts","sourceRoot":"","sources":["../../src/types/desktop-api.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACzD,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACxF,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrF,cAAc,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACnF,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACvF,oBAAoB,EAAE,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IAC/F,iBAAiB,EAAE,MAAM,OAAO,CAAC;QAC/B,SAAS,EAAE,OAAO,CAAC;QACnB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,OAAO,CAAC;QACrB,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC,CAAC;CACJ;AAGD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB"}
1
+ {"version":3,"file":"desktop-api.d.ts","sourceRoot":"","sources":["../../src/types/desktop-api.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACzD,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACxF,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAGzC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrF,cAAc,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACnF,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACvF,oBAAoB,EAAE,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IAC/F,iBAAiB,EAAE,MAAM,OAAO,CAAC;QAC/B,SAAS,EAAE,OAAO,CAAC;QACnB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,OAAO,CAAC;QACrB,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC,CAAC;CACJ;AAGD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-electron",
3
- "version": "1.2.0-alpha.73",
3
+ "version": "1.2.0-alpha.75",
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.0-alpha.73",
29
- "@onekeyfe/hd-shared": "1.2.0-alpha.73",
30
- "@onekeyfe/hd-transport": "1.2.0-alpha.73",
28
+ "@onekeyfe/hd-core": "1.2.0-alpha.75",
29
+ "@onekeyfe/hd-shared": "1.2.0-alpha.75",
30
+ "@onekeyfe/hd-transport": "1.2.0-alpha.75",
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": "5fd2c617e179181cdb15243aec8bbbe28c77cd27"
39
+ "gitHead": "a9b3ab1cb53f87121ababb2b0ce45789be8fe5ad"
40
40
  }
@@ -47,6 +47,9 @@ describe('Electron Noble BLE device discovery', () => {
47
47
  handle: jest.fn((channel: string, handler: IpcHandler) => {
48
48
  handlers.set(channel, handler);
49
49
  }),
50
+ removeHandler: jest.fn((channel: string) => {
51
+ handlers.delete(channel);
52
+ }),
50
53
  };
51
54
  const noble = new EventEmitter() as EventEmitter & {
52
55
  state: string;
@@ -113,6 +116,9 @@ describe('Electron Noble BLE device discovery', () => {
113
116
  handle: jest.fn((channel: string, handler: IpcHandler) => {
114
117
  handlers.set(channel, handler);
115
118
  }),
119
+ removeHandler: jest.fn((channel: string) => {
120
+ handlers.delete(channel);
121
+ }),
116
122
  };
117
123
  const noble = new EventEmitter() as EventEmitter & {
118
124
  state: string;
@@ -181,6 +187,9 @@ describe('Electron Noble BLE device discovery', () => {
181
187
  handle: jest.fn((channel: string, handler: IpcHandler) => {
182
188
  handlers.set(channel, handler);
183
189
  }),
190
+ removeHandler: jest.fn((channel: string) => {
191
+ handlers.delete(channel);
192
+ }),
184
193
  };
185
194
  const noble = new EventEmitter() as EventEmitter & {
186
195
  state: string;
@@ -249,6 +258,9 @@ describe('Electron Noble BLE device discovery', () => {
249
258
  handle: jest.fn((channel: string, handler: IpcHandler) => {
250
259
  handlers.set(channel, handler);
251
260
  }),
261
+ removeHandler: jest.fn((channel: string) => {
262
+ handlers.delete(channel);
263
+ }),
252
264
  };
253
265
  const noble = new EventEmitter() as EventEmitter & {
254
266
  state: string;
@@ -307,6 +319,9 @@ describe('Electron Noble BLE device discovery', () => {
307
319
  handle: jest.fn((channel: string, handler: IpcHandler) => {
308
320
  handlers.set(channel, handler);
309
321
  }),
322
+ removeHandler: jest.fn((channel: string) => {
323
+ handlers.delete(channel);
324
+ }),
310
325
  };
311
326
  const noble = new EventEmitter() as EventEmitter & {
312
327
  state: string;
@@ -29,7 +29,7 @@ import {
29
29
  NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
30
30
  } from './noble-ble-timeouts';
31
31
 
32
- import type { IpcMainInvokeEvent, WebContents } from 'electron';
32
+ import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
33
33
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
34
34
  import type { NobleBleWriteOptions } from './types/desktop-api';
35
35
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
@@ -90,6 +90,10 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
90
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
91
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
92
  const BLE_CLEANUP_TIMEOUT = 250;
93
+ // Renderer release is logical only; this timer physically frees the device.
94
+ const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
95
+ // Ceiling while a call is in flight: no outstanding write, but not forever.
96
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
93
97
 
94
98
  // Write-related constants
95
99
  const BLE_PACKET_SIZE_FALLBACK = 192;
@@ -341,6 +345,76 @@ interface DeviceCleanupOptions {
341
345
  reason?: string;
342
346
  }
343
347
 
348
+ // One timer per device, in the main process so a reload cannot orphan a link.
349
+ const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
350
+
351
+ // A fired timer cannot be cancelled; connect awaits its disconnect or the fast
352
+ // path hands back a link torn down a moment later (BleTimeoutError 713).
353
+ const idleDisconnectInFlight = new Map<string, Promise<void>>();
354
+
355
+ function clearIdleDisconnect(deviceId: string): void {
356
+ const timer = idleDisconnectTimers.get(deviceId);
357
+ if (timer) {
358
+ clearTimeout(timer);
359
+ idleDisconnectTimers.delete(deviceId);
360
+ }
361
+ }
362
+
363
+ /** Settle any keep-alive disconnect already running for this device. */
364
+ async function awaitIdleDisconnect(deviceId: string): Promise<void> {
365
+ const pending = idleDisconnectInFlight.get(deviceId);
366
+ if (pending) await pending.catch(() => undefined);
367
+ }
368
+
369
+ function broadcastToAllWebContents(channel: string, payload: unknown): void {
370
+ try {
371
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
372
+ const { webContents: electronWebContents } = require('electron') as {
373
+ webContents: { getAllWebContents(): WebContents[] };
374
+ };
375
+ for (const wc of electronWebContents.getAllWebContents()) {
376
+ if (!wc.isDestroyed()) wc.send(channel, payload);
377
+ }
378
+ } catch (error) {
379
+ logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
380
+ }
381
+ }
382
+
383
+ function armIdleDisconnect(
384
+ deviceId: string,
385
+ ms: number = BLE_IDLE_DISCONNECT_MS,
386
+ reason: 'idle' | 'busy-backstop' = 'idle'
387
+ ): void {
388
+ clearIdleDisconnect(deviceId);
389
+ idleDisconnectTimers.set(
390
+ deviceId,
391
+ setTimeout(() => {
392
+ idleDisconnectTimers.delete(deviceId);
393
+ if (!connectedDevices.has(deviceId)) return;
394
+ logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
395
+ const peripheral = connectedDevices.get(deviceId);
396
+ const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
397
+ const pending = disconnectDevice(deviceId)
398
+ .then(() => {
399
+ // A call is still in flight here; it must reject, not hang.
400
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
401
+ id: deviceId,
402
+ name: deviceName,
403
+ });
404
+ })
405
+ .catch(error => {
406
+ logger?.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
407
+ })
408
+ .finally(() => {
409
+ if (idleDisconnectInFlight.get(deviceId) === pending) {
410
+ idleDisconnectInFlight.delete(deviceId);
411
+ }
412
+ });
413
+ idleDisconnectInFlight.set(deviceId, pending);
414
+ }, ms)
415
+ );
416
+ }
417
+
344
418
  /**
345
419
  * Unified device cleanup function - single entry point for all cleanup operations
346
420
  */
@@ -366,6 +440,8 @@ function cleanupDevice(
366
440
  cancelOperations,
367
441
  });
368
442
 
443
+ clearIdleDisconnect(deviceId);
444
+
369
445
  // Get device info before cleanup
370
446
  const peripheral = connectedDevices.get(deviceId);
371
447
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
@@ -395,9 +471,11 @@ function cleanupDevice(
395
471
  discoveredDevices.delete(deviceId);
396
472
  }
397
473
 
398
- // 3. Send disconnect event (if needed)
399
- if (sendDisconnectEvent && webContents) {
400
- webContents.send(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
474
+ // 3. Send disconnect event (if needed). Broadcast, not the captured
475
+ // webContents: a kept-alive link outlives a renderer soft restart, and the
476
+ // new renderer still needs to hear the device dropped (same as the idle path).
477
+ if (sendDisconnectEvent) {
478
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
401
479
  id: deviceId,
402
480
  name: deviceName,
403
481
  });
@@ -456,6 +534,8 @@ function setupMtuListener(
456
534
 
457
535
  const listener = (mtu: number) => {
458
536
  if (!Number.isFinite(mtu) || mtu <= 0) return;
537
+ // A kept-alive link can outlive the renderer this closure captured.
538
+ if (webContents.isDestroyed()) return;
459
539
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
460
540
  id: deviceId,
461
541
  mtu,
@@ -600,6 +680,8 @@ async function transmitHexDataToDevice(
600
680
  `Device ${deviceId} not connected or characteristics not available`
601
681
  );
602
682
  }
683
+ // Request outstanding: swap the idle clock for the busy backstop.
684
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
603
685
 
604
686
  const toBuffer = Buffer.from(hexData, 'hex');
605
687
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
@@ -808,20 +890,20 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
808
890
  };
809
891
 
810
892
  // Collect discovered devices into the devices array
811
- const checkDevices = () => {
812
- discoveredDevices.forEach((peripheral, id) => {
813
- const existingDevice = devices.find(d => d.id === id);
814
- if (!existingDevice) {
815
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
816
- devices.push({
817
- commType: 'electron-ble',
818
- id,
819
- name: deviceName,
820
- state: peripheral.state || 'disconnected',
821
- });
822
- }
893
+ const pushDevice = (peripheral: Peripheral, id: string) => {
894
+ if (devices.some(d => d.id === id)) return;
895
+ devices.push({
896
+ commType: 'electron-ble',
897
+ id,
898
+ name: peripheral.advertisement?.localName || 'Unknown Device',
899
+ state: peripheral.state || 'disconnected',
823
900
  });
824
901
  };
902
+ const checkDevices = () => {
903
+ discoveredDevices.forEach(pushDevice);
904
+ // A linked device stops advertising, so the scan never rediscovers it.
905
+ connectedDevices.forEach(pushDevice);
906
+ };
825
907
 
826
908
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
827
909
  const timeoutId = setTimeout(async () => {
@@ -1234,6 +1316,76 @@ async function setupConnectionAndDiscoverServices(
1234
1316
  }
1235
1317
  }
1236
1318
 
1319
+ // noble/mac never resolves for an id CoreBluetooth cannot retrieve.
1320
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1321
+ // Floor after a timeout: do not pay the probe on every attempt.
1322
+ const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
1323
+ const directConnectCooldownUntil = new Map<string, number>();
1324
+
1325
+ /**
1326
+ * Connect straight by id, skipping the ~650ms targeted scan. Both native
1327
+ * backends support it and emit a `discover` as a side effect: macOS resolves
1328
+ * via `retrievePeripheralsWithIdentifiers`, Windows synthesizes one for an
1329
+ * unknown address. Returns undefined when unavailable so the caller scans.
1330
+ *
1331
+ * Ported from the Trezor connector, where it is field-proven.
1332
+ */
1333
+ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1334
+ const nobleInstance = noble as
1335
+ | (typeof noble & {
1336
+ connectAsync?: (id: string) => Promise<Peripheral | undefined>;
1337
+ })
1338
+ | null;
1339
+ if (!nobleInstance?.connectAsync) return undefined;
1340
+
1341
+ const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1342
+ if (Date.now() < cooldownUntil) return undefined;
1343
+
1344
+ try {
1345
+ // The late-orphan guard must attach to THIS pending connect.
1346
+ const directPromise = nobleInstance.connectAsync(deviceId);
1347
+ const raced = await Promise.race([
1348
+ directPromise,
1349
+ new Promise<'timeout'>(resolve => {
1350
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1351
+ }),
1352
+ ]);
1353
+ if (raced === 'timeout') {
1354
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1355
+ logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1356
+ deviceId,
1357
+ });
1358
+ // Promise.race times out the caller only; a late success orphans the link.
1359
+ directPromise
1360
+ .then(late => {
1361
+ const latePeripheral = late ?? discoveredDevices.get(deviceId);
1362
+ if (
1363
+ latePeripheral &&
1364
+ latePeripheral.state === 'connected' &&
1365
+ !connectedDevices.has(deviceId)
1366
+ ) {
1367
+ latePeripheral.removeAllListeners('disconnect');
1368
+ latePeripheral.disconnect(() => undefined);
1369
+ }
1370
+ })
1371
+ .catch(() => undefined);
1372
+ return undefined;
1373
+ }
1374
+ // Backends emit `discover` as a side effect, so the cache may hold it.
1375
+ const peripheral = raced ?? discoveredDevices.get(deviceId);
1376
+ if (!peripheral || peripheral.state !== 'connected') return undefined;
1377
+ logger?.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
1378
+ return peripheral;
1379
+ } catch (error) {
1380
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1381
+ logger?.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
1382
+ deviceId,
1383
+ error: String(error),
1384
+ });
1385
+ return undefined;
1386
+ }
1387
+ }
1388
+
1237
1389
  // Connect to device - supports both discovered and direct connection modes
1238
1390
  async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1239
1391
  logger?.info('[NobleBLE] Connect device request:', {
@@ -1245,7 +1397,8 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1245
1397
  totalConnected: connectedDevices.size,
1246
1398
  });
1247
1399
 
1248
- let peripheral = discoveredDevices.get(deviceId);
1400
+ // enumerate clears the discovery map; a kept-alive link outlives it.
1401
+ let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1249
1402
 
1250
1403
  // If device not discovered, try a targeted scan for this specific device
1251
1404
  if (!peripheral) {
@@ -1260,6 +1413,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1260
1413
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1261
1414
  }
1262
1415
 
1416
+ // Reaches a bonded device that is not advertising; else falls to the scan.
1417
+ peripheral = await tryDirectConnectById(deviceId);
1418
+ if (peripheral) {
1419
+ discoveredDevices.set(deviceId, peripheral);
1420
+ }
1421
+ }
1422
+
1423
+ if (!peripheral) {
1263
1424
  // Perform a targeted scan to find the specific device
1264
1425
  try {
1265
1426
  const foundPeripheral = await performTargetedScan(deviceId);
@@ -1290,9 +1451,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1290
1451
  // If already connected but not in our connected devices map, add it
1291
1452
  if (!connectedDevices.has(deviceId)) {
1292
1453
  connectedDevices.set(deviceId, peripheral);
1293
- // Set up unified disconnect listener
1294
- setupDisconnectListener(peripheral, deviceId, webContents);
1295
1454
  }
1455
+ // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1456
+ // and MTU listeners may still hold the webContents of a soft-restarted
1457
+ // renderer, and the reuse fast path below returns before any other setup.
1458
+ setupDisconnectListener(peripheral, deviceId, webContents);
1296
1459
 
1297
1460
  // Check if we already have characteristics for this device
1298
1461
  if (deviceCharacteristics.has(deviceId)) {
@@ -1336,13 +1499,20 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1336
1499
  }
1337
1500
 
1338
1501
  // Setup connection and discover services
1339
- const characteristics = await setupConnectionAndDiscoverServices(
1340
- peripheral,
1341
- deviceId,
1342
- webContents
1343
- );
1344
- deviceCharacteristics.set(deviceId, characteristics);
1345
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1502
+ try {
1503
+ const characteristics = await setupConnectionAndDiscoverServices(
1504
+ peripheral,
1505
+ deviceId,
1506
+ webContents
1507
+ );
1508
+ deviceCharacteristics.set(deviceId, characteristics);
1509
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1510
+ } catch (setupError) {
1511
+ // The caller re-arms the timer only on success, so a left-up link strands.
1512
+ logger?.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
1513
+ await disconnectDevice(deviceId).catch(() => undefined);
1514
+ throw setupError;
1515
+ }
1346
1516
  return;
1347
1517
  }
1348
1518
 
@@ -1577,12 +1747,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1577
1747
 
1578
1748
  // @ts-ignore – electron is only available at runtime
1579
1749
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1580
- const { ipcMain } = require('electron');
1750
+ const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1751
+
1752
+ // Electron throws on duplicate channels and setup re-runs on soft restart.
1753
+ const handle: IpcMain['handle'] = (channel, listener) => {
1754
+ ipcMain.removeHandler(channel);
1755
+ ipcMain.handle(channel, listener);
1756
+ };
1581
1757
 
1582
1758
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1583
1759
 
1584
1760
  // Handle enumerate request
1585
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1761
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1586
1762
  try {
1587
1763
  const devices = await enumerateDevices();
1588
1764
  safeLog(logger, 'debug', 'Enumeration completed', {
@@ -1597,18 +1773,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1597
1773
  });
1598
1774
 
1599
1775
  // Handle stop scan request
1600
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1776
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1601
1777
  await stopScanning();
1602
1778
  });
1603
1779
 
1604
1780
  // Handle get device request
1605
- ipcMain.handle(
1781
+ handle(
1606
1782
  EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
1607
1783
  (_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
1608
1784
  );
1609
1785
 
1610
1786
  // Handle connect request
1611
- ipcMain.handle(
1787
+ handle(
1612
1788
  EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT,
1613
1789
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1614
1790
  logger?.info('[NobleBLE] IPC CONNECT request received:', {
@@ -1617,12 +1793,37 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1617
1793
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1618
1794
  totalConnectedDevices: connectedDevices.size,
1619
1795
  });
1620
- await connectDevice(deviceId, webContents);
1796
+ // Must not fire mid-connect (pairing can take ~30s).
1797
+ clearIdleDisconnect(deviceId);
1798
+ // A fired timer must settle first, or the fast path returns a dying link.
1799
+ await awaitIdleDisconnect(deviceId);
1800
+ try {
1801
+ await connectDevice(deviceId, webContents);
1802
+ } finally {
1803
+ // This timer is the only thing that frees a kept-alive link.
1804
+ if (connectedDevices.has(deviceId)) {
1805
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1806
+ }
1807
+ }
1808
+ }
1809
+ );
1810
+
1811
+ // Logical release: start the idle countdown, keep the link for the next call.
1812
+ handle(
1813
+ EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
1814
+ (_event: IpcMainInvokeEvent, deviceId: string, keepSession?: boolean) => {
1815
+ if (!connectedDevices.has(deviceId)) return;
1816
+ // Mid-flow caller will be back; the short window would cut an update.
1817
+ if (keepSession) {
1818
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1819
+ return;
1820
+ }
1821
+ armIdleDisconnect(deviceId);
1621
1822
  }
1622
1823
  );
1623
1824
 
1624
1825
  // Handle disconnect request
1625
- ipcMain.handle(
1826
+ handle(
1626
1827
  EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
1627
1828
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1628
1829
  await disconnectDevice(deviceId);
@@ -1630,7 +1831,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1630
1831
  );
1631
1832
 
1632
1833
  // Handle write request
1633
- ipcMain.handle(
1834
+ handle(
1634
1835
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
1635
1836
  async (
1636
1837
  _event: IpcMainInvokeEvent,
@@ -1643,26 +1844,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1643
1844
  );
1644
1845
 
1645
1846
  // Handle subscribe request
1646
- ipcMain.handle(
1847
+ handle(
1647
1848
  EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
1648
1849
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1649
1850
  await subscribeNotifications(deviceId, (data: string) => {
1650
1851
  // Send data back to renderer process
1651
1852
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1652
1853
  });
1854
+ // Still acquiring (in flight) — busy backstop, not the idle clock.
1855
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1653
1856
  }
1654
1857
  );
1655
1858
 
1656
1859
  // Handle unsubscribe request
1657
- ipcMain.handle(
1860
+ handle(
1658
1861
  EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
1659
1862
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1660
1863
  await unsubscribeNotifications(deviceId);
1864
+ armIdleDisconnect(deviceId);
1661
1865
  }
1662
1866
  );
1663
1867
 
1664
1868
  // Handle cancel pairing: cleanup all connected devices
1665
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1869
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1666
1870
  const deviceIds = Array.from(connectedDevices.keys());
1667
1871
  logger?.info('[NobleBLE] Cancel pairing invoked', {
1668
1872
  platform: process.platform,
@@ -1683,7 +1887,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1683
1887
  });
1684
1888
 
1685
1889
  // Handle Bluetooth availability check request
1686
- ipcMain.handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1890
+ handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1687
1891
  try {
1688
1892
  const bluetoothStatus = await checkBluetoothAvailability();
1689
1893
  safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
@@ -12,6 +12,9 @@ export interface NobleBleAPI {
12
12
  enumerate: () => Promise<{ id: string; name: string }[]>;
13
13
  getDevice: (uuid: string) => Promise<{ id: string; name: string; mtu?: number } | null>;
14
14
  connect: (uuid: string) => Promise<void>;
15
+ // Logical end-of-operation: link stays up, idle countdown starts. Optional —
16
+ // older hosts do not bridge it, so the transport feature-detects.
17
+ release?: (uuid: string, keepSession?: boolean) => Promise<void>;
15
18
  disconnect: (uuid: string) => Promise<void>;
16
19
  subscribe: (uuid: string) => Promise<void>;
17
20
  unsubscribe: (uuid: string) => Promise<void>;