@onekeyfe/hd-transport-electron 1.1.34-alpha.0 → 1.1.34-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.
@@ -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-2e41f27c.js'); });
35
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-4423b8bd.js'); });
36
36
  setupNobleBleHandlers(webContents);
37
37
  });
38
38
  }
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ interface NobleModule {
17
17
  stopScanning(callback?: () => void): void;
18
18
  on(event: 'stateChange', listener: (state: string) => void): void;
19
19
  on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
20
+ connectAsync?(idOrAddress: string): Promise<Peripheral | undefined>;
20
21
  removeListener(event: 'stateChange', listener: (state: string) => void): void;
21
22
  removeListener(event: 'discover', listener: (peripheral: Peripheral) => void): void;
22
23
  }
@@ -36,6 +37,7 @@ interface NobleBleAPI {
36
37
  name: string;
37
38
  } | null>;
38
39
  connect: (uuid: string) => Promise<void>;
40
+ release?: (uuid: string) => Promise<void>;
39
41
  disconnect: (uuid: string) => Promise<void>;
40
42
  subscribe: (uuid: string) => Promise<void>;
41
43
  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-de36348d.js');
5
+ var index = require('./index-b69f3b16.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-de36348d.js');
3
+ var index = require('./index-b69f3b16.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var hdTransport = require('@onekeyfe/hd-transport');
6
6
  var pRetry = require('p-retry');
@@ -78,6 +78,10 @@ const subscribedDevices = new Map();
78
78
  const subscriptionOperations = new Map();
79
79
  const devicePacketStates = new Map();
80
80
  const ONEKEY_SERVICE_UUIDS = [hdShared.ONEKEY_SERVICE_UUID];
81
+ const uuid16Key = (uuid) => {
82
+ const stripped = (uuid !== null && uuid !== void 0 ? uuid : '').replace(/-/g, '').toLowerCase();
83
+ return stripped.length >= 8 ? stripped.substring(4, 8) : stripped;
84
+ };
81
85
  const NORMALIZED_WRITE_UUID = '0002';
82
86
  const NORMALIZED_NOTIFY_UUID = '0003';
83
87
  const BLUETOOTH_INIT_TIMEOUT = 10000;
@@ -85,6 +89,9 @@ const DEVICE_SCAN_TIMEOUT = 5000;
85
89
  const FAST_SCAN_TIMEOUT = 1500;
86
90
  const DEVICE_CHECK_INTERVAL = 500;
87
91
  const CONNECTION_TIMEOUT = 3000;
92
+ const BLE_IDLE_DISCONNECT_MS = 60000;
93
+ const BLE_DISCONNECT_TIMEOUT_MS = 2000;
94
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60000;
88
95
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
89
96
  const BLE_PACKET_SIZE = 192;
90
97
  const UNIFIED_WRITE_DELAY = 5;
@@ -244,6 +251,48 @@ function updateBluetoothState(state) {
244
251
  bluetoothState.initialized = true;
245
252
  }
246
253
  }
254
+ const idleDisconnectTimers = new Map();
255
+ function clearIdleDisconnect(deviceId) {
256
+ const timer = idleDisconnectTimers.get(deviceId);
257
+ if (timer) {
258
+ clearTimeout(timer);
259
+ idleDisconnectTimers.delete(deviceId);
260
+ }
261
+ }
262
+ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle') {
263
+ clearIdleDisconnect(deviceId);
264
+ idleDisconnectTimers.set(deviceId, setTimeout(() => {
265
+ var _a;
266
+ idleDisconnectTimers.delete(deviceId);
267
+ if (!connectedDevices.has(deviceId))
268
+ return;
269
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Keep-alive timeout, disconnecting device:', deviceId, reason);
270
+ const peripheral = connectedDevices.get(deviceId);
271
+ const deviceName = ((_a = peripheral === null || peripheral === void 0 ? void 0 : peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
272
+ disconnectDevice(deviceId)
273
+ .then(() => {
274
+ broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
275
+ id: deviceId,
276
+ name: deviceName,
277
+ });
278
+ })
279
+ .catch(error => {
280
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Keep-alive disconnect failed:', error);
281
+ });
282
+ }, ms));
283
+ }
284
+ function broadcastToAllWebContents(channel, payload) {
285
+ try {
286
+ const { webContents: electronWebContents } = require('electron');
287
+ for (const wc of electronWebContents.getAllWebContents()) {
288
+ if (!wc.isDestroyed())
289
+ wc.send(channel, payload);
290
+ }
291
+ }
292
+ catch (error) {
293
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
294
+ }
295
+ }
247
296
  function initializeNoble() {
248
297
  return index.__awaiter(this, void 0, void 0, function* () {
249
298
  if (noble)
@@ -315,6 +364,7 @@ function cleanupDevice(deviceId, webContents, options = {}) {
315
364
  sendDisconnectEvent,
316
365
  cancelOperations,
317
366
  });
367
+ clearIdleDisconnect(deviceId);
318
368
  const peripheral = connectedDevices.get(deviceId);
319
369
  const deviceName = ((_a = peripheral === null || peripheral === void 0 ? void 0 : peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
320
370
  if (cleanupConnection) {
@@ -457,6 +507,7 @@ function transmitHexDataToDevice(deviceId, hexData) {
457
507
  if (!peripheral || !characteristics) {
458
508
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleCharacteristicNotFound, `Device ${deviceId} not connected or characteristics not available`);
459
509
  }
510
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
460
511
  const toBuffer = Buffer.from(hexData, 'hex');
461
512
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Writing data:', {
462
513
  deviceId,
@@ -706,7 +757,7 @@ function discoverServicesAndCharacteristics(peripheral) {
706
757
  });
707
758
  const discoveryPromise = (() => index.__awaiter(this, void 0, void 0, function* () {
708
759
  const services = yield new Promise((resolve, reject) => {
709
- peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
760
+ peripheral.discoverServices([], (error, svc) => {
710
761
  if (error) {
711
762
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed:', error);
712
763
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, error.message));
@@ -719,10 +770,14 @@ function discoverServicesAndCharacteristics(peripheral) {
719
770
  if (!services || services.length === 0) {
720
771
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
721
772
  }
722
- const service = services[0];
773
+ const wanted = ONEKEY_SERVICE_UUIDS.map(uuid16Key);
774
+ const service = services.find(svc => wanted.includes(uuid16Key(svc.uuid)));
775
+ if (!service) {
776
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, `No OneKey service in result set: ${services.map(svc => svc.uuid).join('|')}`);
777
+ }
723
778
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Found service:', service.uuid);
724
779
  const characteristics = yield new Promise((resolve, reject) => {
725
- service.discoverCharacteristics([hdShared.ONEKEY_WRITE_CHARACTERISTIC_UUID, hdShared.ONEKEY_NOTIFY_CHARACTERISTIC_UUID], (error, chars) => {
780
+ service.discoverCharacteristics([], (error, chars) => {
726
781
  if (error) {
727
782
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Characteristic discovery failed:', error);
728
783
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleCharacteristicNotFound, error.message));
@@ -801,8 +856,21 @@ function forceReconnectPeripheral(peripheral, deviceId) {
801
856
  });
802
857
  }
803
858
  function freshScanAndDiscover(deviceId, webContents) {
859
+ var _a;
804
860
  return index.__awaiter(this, void 0, void 0, function* () {
805
861
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Performing fresh scan to get new peripheral object for device:', deviceId);
862
+ const stalePeripheral = (_a = connectedDevices.get(deviceId)) !== null && _a !== void 0 ? _a : discoveredDevices.get(deviceId);
863
+ if (stalePeripheral && stalePeripheral.state === 'connected') {
864
+ stalePeripheral.removeAllListeners('disconnect');
865
+ yield new Promise(resolve => {
866
+ const timer = setTimeout(resolve, BLE_DISCONNECT_TIMEOUT_MS);
867
+ stalePeripheral.disconnect(() => {
868
+ clearTimeout(timer);
869
+ resolve();
870
+ });
871
+ });
872
+ connectedDevices.delete(deviceId);
873
+ }
806
874
  const freshPeripheral = yield performTargetedScan(deviceId);
807
875
  if (!freshPeripheral) {
808
876
  discoveredDevices.delete(deviceId);
@@ -827,7 +895,13 @@ function freshScanAndDiscover(deviceId, webContents) {
827
895
  setupDisconnectListener(freshPeripheral, deviceId, webContents);
828
896
  yield hdShared.wait(500);
829
897
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Attempting service discovery with fresh peripheral');
830
- return discoverServicesAndCharacteristics(freshPeripheral);
898
+ try {
899
+ return yield discoverServicesAndCharacteristics(freshPeripheral);
900
+ }
901
+ catch (error) {
902
+ yield disconnectDevice(deviceId).catch(() => undefined);
903
+ throw error;
904
+ }
831
905
  });
832
906
  }
833
907
  function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
@@ -873,10 +947,22 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
873
947
  }
874
948
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
875
949
  return index.__awaiter(this, void 0, void 0, function* () {
876
- yield forceReconnectPeripheral(peripheral, deviceId);
877
950
  setupDisconnectListener(peripheral, deviceId, webContents);
951
+ if (peripheral.state === 'connected') {
952
+ try {
953
+ const result = yield discoverServicesAndCharacteristics(peripheral);
954
+ connectedDevices.set(deviceId, peripheral);
955
+ return result;
956
+ }
957
+ catch (directError) {
958
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct discovery miss, escalating:', String(directError));
959
+ }
960
+ }
878
961
  try {
879
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
962
+ yield forceReconnectPeripheral(peripheral, deviceId);
963
+ setupDisconnectListener(peripheral, deviceId, webContents);
964
+ const result = yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
965
+ return result;
880
966
  }
881
967
  catch (error) {
882
968
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
@@ -884,6 +970,51 @@ function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
884
970
  }
885
971
  });
886
972
  }
973
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
974
+ const DIRECT_CONNECT_COOLDOWN_MS = 15000;
975
+ const directConnectCooldownUntil = new Map();
976
+ function tryDirectConnectById(deviceId) {
977
+ var _a;
978
+ return index.__awaiter(this, void 0, void 0, function* () {
979
+ if (!noble || typeof noble.connectAsync !== 'function')
980
+ return undefined;
981
+ if (((_a = directConnectCooldownUntil.get(deviceId)) !== null && _a !== void 0 ? _a : 0) > Date.now()) {
982
+ return undefined;
983
+ }
984
+ try {
985
+ const directPromise = noble.connectAsync(deviceId);
986
+ const raced = yield Promise.race([
987
+ directPromise,
988
+ new Promise(resolve => {
989
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
990
+ }),
991
+ ]);
992
+ if (raced === 'timeout') {
993
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
994
+ directPromise
995
+ .then(late => {
996
+ const latePeripheral = late !== null && late !== void 0 ? late : discoveredDevices.get(deviceId);
997
+ if (latePeripheral &&
998
+ latePeripheral.state === 'connected' &&
999
+ !connectedDevices.has(deviceId)) {
1000
+ latePeripheral.removeAllListeners('disconnect');
1001
+ latePeripheral.disconnect(() => { });
1002
+ }
1003
+ })
1004
+ .catch(() => { });
1005
+ return undefined;
1006
+ }
1007
+ const peripheral = raced !== null && raced !== void 0 ? raced : discoveredDevices.get(deviceId);
1008
+ if (!peripheral || peripheral.state !== 'connected')
1009
+ return undefined;
1010
+ discoveredDevices.set(deviceId, peripheral);
1011
+ return peripheral;
1012
+ }
1013
+ catch (error) {
1014
+ return undefined;
1015
+ }
1016
+ });
1017
+ }
887
1018
  function connectDevice(deviceId, webContents) {
888
1019
  return index.__awaiter(this, void 0, void 0, function* () {
889
1020
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
@@ -903,8 +1034,9 @@ function connectDevice(deviceId, webContents) {
903
1034
  if (!noble) {
904
1035
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
905
1036
  }
1037
+ peripheral = yield tryDirectConnectById(deviceId);
906
1038
  try {
907
- const foundPeripheral = yield performTargetedScan(deviceId);
1039
+ const foundPeripheral = peripheral !== null && peripheral !== void 0 ? peripheral : (yield performTargetedScan(deviceId));
908
1040
  if (!foundPeripheral) {
909
1041
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found even after targeted scan`);
910
1042
  }
@@ -953,12 +1085,21 @@ function connectDevice(deviceId, webContents) {
953
1085
  return;
954
1086
  }
955
1087
  return new Promise((resolve, reject) => {
1088
+ let timedOut = false;
956
1089
  const timeout = setTimeout(() => {
1090
+ timedOut = true;
957
1091
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, 'Connection timeout'));
958
1092
  }, CONNECTION_TIMEOUT);
959
1093
  const connectedPeripheral = peripheral;
960
1094
  connectedPeripheral.connect((error) => index.__awaiter(this, void 0, void 0, function* () {
961
1095
  clearTimeout(timeout);
1096
+ if (timedOut) {
1097
+ if (!error && connectedPeripheral.state === 'connected') {
1098
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Late connection after timeout, disconnecting:', deviceId);
1099
+ connectedPeripheral.disconnect(() => { });
1100
+ }
1101
+ return;
1102
+ }
962
1103
  if (error) {
963
1104
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection failed:', error);
964
1105
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, error.message));
@@ -990,7 +1131,11 @@ function disconnectDevice(deviceId) {
990
1131
  }
991
1132
  return new Promise(resolve => {
992
1133
  peripheral.removeAllListeners('disconnect');
993
- peripheral.disconnect(() => {
1134
+ let settled = false;
1135
+ const finish = () => {
1136
+ if (settled)
1137
+ return;
1138
+ settled = true;
994
1139
  cleanupDevice(deviceId, undefined, {
995
1140
  cleanupConnection: true,
996
1141
  sendDisconnectEvent: false,
@@ -998,6 +1143,11 @@ function disconnectDevice(deviceId) {
998
1143
  reason: 'manual-disconnect',
999
1144
  });
1000
1145
  resolve();
1146
+ };
1147
+ const timer = setTimeout(finish, BLE_DISCONNECT_TIMEOUT_MS);
1148
+ peripheral.disconnect(() => {
1149
+ clearTimeout(timer);
1150
+ finish();
1001
1151
  });
1002
1152
  });
1003
1153
  });
@@ -1162,11 +1312,17 @@ function setupNobleBleHandlers(webContents) {
1162
1312
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1163
1313
  totalConnectedDevices: connectedDevices.size,
1164
1314
  });
1315
+ clearIdleDisconnect(deviceId);
1165
1316
  yield connectDevice(deviceId, webContents);
1317
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1166
1318
  }));
1167
1319
  ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1168
1320
  yield disconnectDevice(deviceId);
1169
1321
  }));
1322
+ ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE, (_event, deviceId) => {
1323
+ if (connectedDevices.has(deviceId))
1324
+ armIdleDisconnect(deviceId);
1325
+ });
1170
1326
  ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_WRITE, (_event, deviceId, hexData) => index.__awaiter(this, void 0, void 0, function* () {
1171
1327
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] IPC WRITE', { deviceId, len: hexData.length });
1172
1328
  yield transmitHexDataToDevice(deviceId, hexData);
@@ -1175,9 +1331,11 @@ function setupNobleBleHandlers(webContents) {
1175
1331
  yield subscribeNotifications(deviceId, (data) => {
1176
1332
  webContents.send(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1177
1333
  });
1334
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1178
1335
  }));
1179
1336
  ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE, (_event, deviceId) => index.__awaiter(this, void 0, void 0, function* () {
1180
1337
  yield unsubscribeNotifications(deviceId);
1338
+ armIdleDisconnect(deviceId);
1181
1339
  }));
1182
1340
  ipcMain.handle(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, () => index.__awaiter(this, void 0, void 0, function* () {
1183
1341
  const deviceIds = Array.from(connectedDevices.keys());
@@ -1215,12 +1373,19 @@ function setupNobleBleHandlers(webContents) {
1215
1373
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1216
1374
  const deviceIds = Array.from(connectedDevices.keys());
1217
1375
  deviceIds.forEach(deviceId => {
1376
+ const peripheral = connectedDevices.get(deviceId);
1218
1377
  cleanupDevice(deviceId, undefined, {
1219
1378
  cleanupConnection: true,
1220
1379
  sendDisconnectEvent: false,
1221
1380
  cancelOperations: true,
1222
1381
  reason: 'app-quit',
1223
1382
  });
1383
+ if (peripheral && peripheral.state === 'connected') {
1384
+ peripheral.removeAllListeners('disconnect');
1385
+ peripheral.disconnect(() => {
1386
+ safeLog(logger, 'info', `Disconnected ${deviceId} on window teardown`);
1387
+ });
1388
+ }
1224
1389
  });
1225
1390
  stopScanning();
1226
1391
  if (noble && persistentStateListener) {
@@ -1 +1 @@
1
- {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,UAAU,CAAC;AAshDhE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAsKpE"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,UAAU,CAAC;AA8uDhE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAmMpE"}
@@ -8,6 +8,7 @@ export interface NobleBleAPI {
8
8
  name: string;
9
9
  } | null>;
10
10
  connect: (uuid: string) => Promise<void>;
11
+ release?: (uuid: string) => Promise<void>;
11
12
  disconnect: (uuid: string) => Promise<void>;
12
13
  subscribe: (uuid: string) => Promise<void>;
13
14
  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,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,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IAC1E,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,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,cAAc,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACnF,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,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,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IAC1E,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,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,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,cAAc,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACnF,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"}
@@ -15,6 +15,7 @@ export interface NobleModule {
15
15
  stopScanning(callback?: () => void): void;
16
16
  on(event: 'stateChange', listener: (state: string) => void): void;
17
17
  on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
18
+ connectAsync?(idOrAddress: string): Promise<Peripheral | undefined>;
18
19
  removeListener(event: 'stateChange', listener: (state: string) => void): void;
19
20
  removeListener(event: 'discover', listener: (peripheral: Peripheral) => void): void;
20
21
  }
@@ -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;CACf;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,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,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;CACf;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,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;IASxE,YAAY,CAAC,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IACpE,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,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.1.34-alpha.0",
3
+ "version": "1.1.34-alpha.2",
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.1.34-alpha.0",
29
- "@onekeyfe/hd-shared": "1.1.34-alpha.0",
30
- "@onekeyfe/hd-transport": "1.1.34-alpha.0",
28
+ "@onekeyfe/hd-core": "1.1.34-alpha.2",
29
+ "@onekeyfe/hd-shared": "1.1.34-alpha.2",
30
+ "@onekeyfe/hd-transport": "1.1.34-alpha.2",
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": "178851631a3d06dd52c8dd9df24cbaaff7aa6390"
39
+ "gitHead": "dee167c35d191f3f4e02c4cb6228e1f926f929ec"
40
40
  }
@@ -9,9 +9,7 @@ import {
9
9
  EOneKeyBleMessageKeys,
10
10
  ERRORS,
11
11
  HardwareErrorCode,
12
- ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
13
12
  ONEKEY_SERVICE_UUID,
14
- ONEKEY_WRITE_CHARACTERISTIC_UUID,
15
13
  isHeaderChunk,
16
14
  isOnekeyDevice,
17
15
  wait,
@@ -74,6 +72,13 @@ const devicePacketStates = new Map<string, PacketAssemblyState>();
74
72
  const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
75
73
 
76
74
  // Pre-normalized characteristic identifiers for fast comparison
75
+ // Reduce any uuid form (long base-form with/without dashes, or short) to its
76
+ // 16-bit key for comparisons — noble/mac reports base-UUIDs short-form.
77
+ const uuid16Key = (uuid: string): string => {
78
+ const stripped = (uuid ?? '').replace(/-/g, '').toLowerCase();
79
+ return stripped.length >= 8 ? stripped.substring(4, 8) : stripped;
80
+ };
81
+
77
82
  const NORMALIZED_WRITE_UUID = '0002';
78
83
  const NORMALIZED_NOTIFY_UUID = '0003';
79
84
 
@@ -83,6 +88,15 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
83
88
  const FAST_SCAN_TIMEOUT = 1500; // 1.5 seconds for fast targeted scanning
84
89
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
85
90
  const CONNECTION_TIMEOUT = 3000; // 3 seconds for device connection
91
+ // Idle window before a kept-alive link is dropped so other hosts (phone) can
92
+ // connect; matches the device's 60s auto-lock default.
93
+ const BLE_IDLE_DISCONNECT_MS = 60_000;
94
+ // noble's disconnect callback can hang on a wedged peripheral — bound it.
95
+ const BLE_DISCONNECT_TIMEOUT_MS = 2000;
96
+ // Backstop while a request is outstanding: the idle clock must not run while
97
+ // the user is on the device's confirm screen, but a dead call must not hold
98
+ // the link forever either.
99
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
86
100
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
87
101
 
88
102
  // Write-related constants
@@ -302,6 +316,64 @@ function updateBluetoothState(state: string): void {
302
316
  }
303
317
  }
304
318
 
319
+ // ===== Keep-alive idle disconnect =====
320
+ // One timer per device: a write swaps it for the busy backstop, a complete
321
+ // response re-arms the 60s idle clock. Lives in the main process so a renderer
322
+ // reload cannot orphan a held link.
323
+ const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
324
+
325
+ function clearIdleDisconnect(deviceId: string): void {
326
+ const timer = idleDisconnectTimers.get(deviceId);
327
+ if (timer) {
328
+ clearTimeout(timer);
329
+ idleDisconnectTimers.delete(deviceId);
330
+ }
331
+ }
332
+
333
+ function armIdleDisconnect(
334
+ deviceId: string,
335
+ ms: number = BLE_IDLE_DISCONNECT_MS,
336
+ reason: 'idle' | 'busy-backstop' = 'idle'
337
+ ): void {
338
+ clearIdleDisconnect(deviceId);
339
+ idleDisconnectTimers.set(
340
+ deviceId,
341
+ setTimeout(() => {
342
+ idleDisconnectTimers.delete(deviceId);
343
+ if (!connectedDevices.has(deviceId)) return;
344
+ logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device:', deviceId, reason);
345
+ const peripheral = connectedDevices.get(deviceId);
346
+ const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
347
+ disconnectDevice(deviceId)
348
+ .then(() => {
349
+ // Notify renderers: on the busy backstop a call is still in flight
350
+ // and must reject instead of hanging on a dead link.
351
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
352
+ id: deviceId,
353
+ name: deviceName,
354
+ });
355
+ })
356
+ .catch(error => {
357
+ logger?.error('[NobleBLE] Keep-alive disconnect failed:', error);
358
+ });
359
+ }, ms)
360
+ );
361
+ }
362
+
363
+ function broadcastToAllWebContents(channel: string, payload: unknown): void {
364
+ try {
365
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
366
+ const { webContents: electronWebContents } = require('electron') as {
367
+ webContents: { getAllWebContents(): WebContents[] };
368
+ };
369
+ for (const wc of electronWebContents.getAllWebContents()) {
370
+ if (!wc.isDestroyed()) wc.send(channel, payload);
371
+ }
372
+ } catch (error) {
373
+ logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
374
+ }
375
+ }
376
+
305
377
  // Initialize Noble
306
378
  async function initializeNoble(): Promise<void> {
307
379
  if (noble) return;
@@ -418,6 +490,7 @@ function cleanupDevice(
418
490
  sendDisconnectEvent,
419
491
  cancelOperations,
420
492
  });
493
+ clearIdleDisconnect(deviceId);
421
494
 
422
495
  // Get device info before cleanup
423
496
  const peripheral = connectedDevices.get(deviceId);
@@ -616,6 +689,9 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
616
689
  `Device ${deviceId} not connected or characteristics not available`
617
690
  );
618
691
  }
692
+ // Request outstanding: swap the idle clock for the busy backstop; the
693
+ // complete-response handler re-arms the 60s idle clock.
694
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
619
695
 
620
696
  const toBuffer = Buffer.from(hexData, 'hex');
621
697
  logger?.info('[NobleBLE] Writing data:', {
@@ -952,9 +1028,13 @@ async function discoverServicesAndCharacteristics(
952
1028
 
953
1029
  // Main discovery logic as async function
954
1030
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
955
- // Step 1: Discover services (promisified)
1031
+ // Step 1: Discover services (promisified). In unfiltered mode we discover
1032
+ // everything and pick the OneKey service in JS — field data (Classic,
1033
+ // 2026-07-19) showed the UUID-filtered query returning empty on a link
1034
+ // where an unfiltered probe saw 5 services, so the filter itself is under
1035
+ // suspicion (same failure family as the Windows scan-filter issue).
956
1036
  const services = await new Promise<Service[]>((resolve, reject) => {
957
- peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1037
+ peripheral.discoverServices([], (error, svc) => {
958
1038
  if (error) {
959
1039
  logger?.error('[NobleBLE] Service discovery failed:', error);
960
1040
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -968,22 +1048,28 @@ async function discoverServicesAndCharacteristics(
968
1048
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
969
1049
  }
970
1050
 
971
- const service = services[0];
1051
+ // Pick by 16-bit key: constants are long base-forms, noble/mac reports
1052
+ // base-UUID services short-form ('0001').
1053
+ const wanted = ONEKEY_SERVICE_UUIDS.map(uuid16Key);
1054
+ const service = services.find(svc => wanted.includes(uuid16Key(svc.uuid)));
1055
+ if (!service) {
1056
+ // Carry the uuid list so the trace shows what the device exposed.
1057
+ throw ERRORS.TypedError(
1058
+ HardwareErrorCode.BleServiceNotFound,
1059
+ `No OneKey service in result set: ${services.map(svc => svc.uuid).join('|')}`
1060
+ );
1061
+ }
972
1062
  logger?.info('[NobleBLE] Found service:', service.uuid);
973
1063
 
974
- // Step 2: Discover characteristics (promisified)
975
1064
  const characteristics = await new Promise<Characteristic[]>((resolve, reject) => {
976
- service.discoverCharacteristics(
977
- [ONEKEY_WRITE_CHARACTERISTIC_UUID, ONEKEY_NOTIFY_CHARACTERISTIC_UUID],
978
- (error, chars) => {
979
- if (error) {
980
- logger?.error('[NobleBLE] Characteristic discovery failed:', error);
981
- reject(ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound, error.message));
982
- } else {
983
- resolve(chars);
984
- }
1065
+ service.discoverCharacteristics([], (error, chars) => {
1066
+ if (error) {
1067
+ logger?.error('[NobleBLE] Characteristic discovery failed:', error);
1068
+ reject(ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound, error.message));
1069
+ } else {
1070
+ resolve(chars);
985
1071
  }
986
- );
1072
+ });
987
1073
  });
988
1074
 
989
1075
  // Step 3: Find required characteristics
@@ -1086,7 +1172,6 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1086
1172
  // NOTE: Caller MUST call setupDisconnectListener() after this function returns
1087
1173
  }
1088
1174
 
1089
- // Last resort: Fresh scan to get completely new peripheral object and discover services
1090
1175
  async function freshScanAndDiscover(
1091
1176
  deviceId: string,
1092
1177
  webContents: WebContents
@@ -1096,6 +1181,21 @@ async function freshScanAndDiscover(
1096
1181
  deviceId
1097
1182
  );
1098
1183
 
1184
+ // The device does not advertise while we hold a link — drop the old one
1185
+ // first (bounded) so it goes back on air before we scan.
1186
+ const stalePeripheral = connectedDevices.get(deviceId) ?? discoveredDevices.get(deviceId);
1187
+ if (stalePeripheral && stalePeripheral.state === 'connected') {
1188
+ stalePeripheral.removeAllListeners('disconnect');
1189
+ await new Promise<void>(resolve => {
1190
+ const timer = setTimeout(resolve, BLE_DISCONNECT_TIMEOUT_MS);
1191
+ stalePeripheral.disconnect(() => {
1192
+ clearTimeout(timer);
1193
+ resolve();
1194
+ });
1195
+ });
1196
+ connectedDevices.delete(deviceId);
1197
+ }
1198
+
1099
1199
  const freshPeripheral = await performTargetedScan(deviceId);
1100
1200
  if (!freshPeripheral) {
1101
1201
  // Deep cleanup: fresh scan found no device, reset to initial state
@@ -1136,9 +1236,17 @@ async function freshScanAndDiscover(
1136
1236
  // Wait for connection to stabilize (fresh peripheral doesn't need GATT cache clearing)
1137
1237
  await wait(500);
1138
1238
 
1139
- // Attempt service discovery with fresh peripheral
1239
+ // Attempt service discovery with fresh peripheral. On failure the fresh
1240
+ // peripheral is already physically connected and registered in
1241
+ // connectedDevices with no idle timer armed — keep-alive release() is
1242
+ // logical-only and would never drop it — so tear it down before rethrowing.
1140
1243
  logger?.info('[NobleBLE] Attempting service discovery with fresh peripheral');
1141
- return discoverServicesAndCharacteristics(freshPeripheral);
1244
+ try {
1245
+ return await discoverServicesAndCharacteristics(freshPeripheral);
1246
+ } catch (error) {
1247
+ await disconnectDevice(deviceId).catch(() => undefined);
1248
+ throw error;
1249
+ }
1142
1250
  }
1143
1251
 
1144
1252
  // Enhanced service discovery with p-retry for robust BLE connection
@@ -1209,20 +1317,94 @@ async function setupConnectionAndDiscoverServices(
1209
1317
  deviceId: string,
1210
1318
  webContents: WebContents
1211
1319
  ): Promise<CharacteristicPair> {
1212
- // Force reconnect to clear GATT cache
1213
- await forceReconnectPeripheral(peripheral, deviceId);
1214
1320
  setupDisconnectListener(peripheral, deviceId, webContents);
1215
1321
 
1216
- // Discover services and characteristics with retry
1322
+ // Optimistic direct discovery first; the force-reconnect fallback below only
1323
+ // runs if it fails. Discovery is UNFILTERED with 16-bit-key matching in JS:
1324
+ // the device answers only the 2-byte-encoded targeted query, so a uuid-
1325
+ // filtered discovery is unreliable (macOS CoreBluetooth sends the 128-bit
1326
+ // base form and misses). See uuid16Key.
1327
+ if (peripheral.state === 'connected') {
1328
+ try {
1329
+ const result = await discoverServicesAndCharacteristics(peripheral);
1330
+ connectedDevices.set(deviceId, peripheral);
1331
+ return result;
1332
+ } catch (directError) {
1333
+ logger?.info('[NobleBLE] Direct discovery miss, escalating:', String(directError));
1334
+ }
1335
+ }
1336
+
1337
+ // Fallback ladder; a force-reconnect failure must also escalate to the
1338
+ // fresh scan instead of propagating out.
1217
1339
  try {
1218
- return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1340
+ await forceReconnectPeripheral(peripheral, deviceId);
1341
+ // forceReconnectPeripheral strips listeners — re-attach.
1342
+ setupDisconnectListener(peripheral, deviceId, webContents);
1343
+
1344
+ const result = await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1345
+ return result;
1219
1346
  } catch (error) {
1220
1347
  // Last resort: fresh scan to get new peripheral object
1221
1348
  logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1349
+ // Broken-OS-bond signature lands here.
1222
1350
  return freshScanAndDiscover(deviceId, webContents);
1223
1351
  }
1224
1352
  }
1225
1353
 
1354
+ // noble/mac never resolves connect-by-id for an unretrievable peripheral —
1355
+ // time-box it, and after a timeout skip direct for a while so the retry
1356
+ // ladder doesn't re-pay the 2s against an absent device.
1357
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1358
+ const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
1359
+ const directConnectCooldownUntil = new Map<string, number>();
1360
+
1361
+ /**
1362
+ * Bounded connect-by-id with no scan; undefined -> caller falls back to the
1363
+ * targeted scan. A late success after the timeout is dropped unless claimed.
1364
+ */
1365
+ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1366
+ if (!noble || typeof noble.connectAsync !== 'function') return undefined;
1367
+ if ((directConnectCooldownUntil.get(deviceId) ?? 0) > Date.now()) {
1368
+ return undefined;
1369
+ }
1370
+ try {
1371
+ // The late-orphan guard must attach to THIS pending connect.
1372
+ const directPromise = noble.connectAsync(deviceId);
1373
+ const raced = await Promise.race([
1374
+ directPromise,
1375
+ new Promise<'timeout'>(resolve => {
1376
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1377
+ }),
1378
+ ]);
1379
+ if (raced === 'timeout') {
1380
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1381
+ // If the pending connect completes later, drop it unless someone claimed it.
1382
+ directPromise
1383
+ .then(late => {
1384
+ const latePeripheral = late ?? discoveredDevices.get(deviceId);
1385
+ if (
1386
+ latePeripheral &&
1387
+ latePeripheral.state === 'connected' &&
1388
+ !connectedDevices.has(deviceId)
1389
+ ) {
1390
+ latePeripheral.removeAllListeners('disconnect');
1391
+ latePeripheral.disconnect(() => {});
1392
+ }
1393
+ })
1394
+ .catch(() => {});
1395
+ return undefined;
1396
+ }
1397
+ // Backends emit a `discover` for the peripheral as a side effect, so the
1398
+ // cache may hold it even when connectAsync resolves without a value.
1399
+ const peripheral = raced ?? discoveredDevices.get(deviceId);
1400
+ if (!peripheral || peripheral.state !== 'connected') return undefined;
1401
+ discoveredDevices.set(deviceId, peripheral);
1402
+ return peripheral;
1403
+ } catch (error) {
1404
+ return undefined;
1405
+ }
1406
+ }
1407
+
1226
1408
  // Connect to device - supports both discovered and direct connection modes
1227
1409
  async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1228
1410
  logger?.info('[NobleBLE] Connect device request:', {
@@ -1249,9 +1431,13 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1249
1431
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1250
1432
  }
1251
1433
 
1434
+ // Bounded connect-by-id first: skips the ~650ms targeted scan and reaches
1435
+ // a device that is not advertising; falls through to the scan on timeout.
1436
+ peripheral = await tryDirectConnectById(deviceId);
1437
+
1252
1438
  // Perform a targeted scan to find the specific device
1253
1439
  try {
1254
- const foundPeripheral = await performTargetedScan(deviceId);
1440
+ const foundPeripheral = peripheral ?? (await performTargetedScan(deviceId));
1255
1441
  if (!foundPeripheral) {
1256
1442
  throw ERRORS.TypedError(
1257
1443
  HardwareErrorCode.DeviceNotFound,
@@ -1337,7 +1523,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1337
1523
  }
1338
1524
 
1339
1525
  return new Promise((resolve, reject) => {
1526
+ // After the timeout rejects, a late successful connect would be an
1527
+ // ownerless link with no idle timer — detect and drop it.
1528
+ let timedOut = false;
1340
1529
  const timeout = setTimeout(() => {
1530
+ timedOut = true;
1341
1531
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1342
1532
  }, CONNECTION_TIMEOUT);
1343
1533
 
@@ -1346,6 +1536,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1346
1536
  connectedPeripheral.connect(async (error: Error | undefined) => {
1347
1537
  clearTimeout(timeout);
1348
1538
 
1539
+ if (timedOut) {
1540
+ if (!error && connectedPeripheral.state === 'connected') {
1541
+ logger?.info('[NobleBLE] Late connection after timeout, disconnecting:', deviceId);
1542
+ connectedPeripheral.disconnect(() => {});
1543
+ }
1544
+ return;
1545
+ }
1546
+
1349
1547
  if (error) {
1350
1548
  logger?.error('[NobleBLE] Connection failed:', error);
1351
1549
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
@@ -1386,7 +1584,13 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1386
1584
  // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1387
1585
  peripheral.removeAllListeners('disconnect');
1388
1586
 
1389
- peripheral.disconnect(() => {
1587
+ // Time-box the disconnect: on a wedged peripheral CoreBluetooth may never
1588
+ // invoke the callback, and callers (e.g. the idle/backstop timer) rely on
1589
+ // this resolving so cleanup + the renderer-facing disconnect broadcast run.
1590
+ let settled = false;
1591
+ const finish = () => {
1592
+ if (settled) return;
1593
+ settled = true;
1390
1594
  // Clean up device state using unified function
1391
1595
  cleanupDevice(deviceId, undefined, {
1392
1596
  cleanupConnection: true,
@@ -1395,6 +1599,11 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1395
1599
  reason: 'manual-disconnect',
1396
1600
  });
1397
1601
  resolve();
1602
+ };
1603
+ const timer = setTimeout(finish, BLE_DISCONNECT_TIMEOUT_MS);
1604
+ peripheral.disconnect(() => {
1605
+ clearTimeout(timer);
1606
+ finish();
1398
1607
  });
1399
1608
  });
1400
1609
  }
@@ -1564,6 +1773,11 @@ async function subscribeNotifications(
1564
1773
  return;
1565
1774
  }
1566
1775
  if (result.isComplete && result.completePacket) {
1776
+ // A complete response does NOT mean the operation is over — the run
1777
+ // may continue (further request/response, or an on-device prompt whose
1778
+ // Ack the host writes after slow human input). Do NOT re-arm the 60s
1779
+ // idle clock here; that starts only on logical release. The busy
1780
+ // backstop remains the in-flight ceiling.
1567
1781
  const appCb = notificationCallbacks.get(deviceId);
1568
1782
  if (appCb) appCb(result.completePacket);
1569
1783
  }
@@ -1632,7 +1846,15 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1632
1846
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1633
1847
  totalConnectedDevices: connectedDevices.size,
1634
1848
  });
1849
+ // An armed idle timer must not fire mid-connect (pairing can take ~30s).
1850
+ clearIdleDisconnect(deviceId);
1635
1851
  await connectDevice(deviceId, webContents);
1852
+ // A logical operation is now in flight (acquire). Arm the long busy
1853
+ // backstop, NOT the 60s idle clock — the 60s countdown starts only when
1854
+ // the renderer signals logical release (NOBLE_BLE_RELEASE). This keeps
1855
+ // the link up across slow on-device prompts (PIN/passphrase) that have
1856
+ // no outstanding write.
1857
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1636
1858
  }
1637
1859
  );
1638
1860
 
@@ -1644,6 +1866,16 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1644
1866
  }
1645
1867
  );
1646
1868
 
1869
+ // Handle logical release: the operation is done, start the 60s idle
1870
+ // countdown so an unused device is freed for other hosts. The physical
1871
+ // link is kept (reused by the next call) unless the countdown elapses.
1872
+ ipcMain.handle(
1873
+ EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
1874
+ (_event: IpcMainInvokeEvent, deviceId: string) => {
1875
+ if (connectedDevices.has(deviceId)) armIdleDisconnect(deviceId);
1876
+ }
1877
+ );
1878
+
1647
1879
  // Handle write request
1648
1880
  ipcMain.handle(
1649
1881
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
@@ -1661,6 +1893,8 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1661
1893
  // Send data back to renderer process
1662
1894
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1663
1895
  });
1896
+ // Still acquiring (in flight) — busy backstop, not the 60s idle clock.
1897
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1664
1898
  }
1665
1899
  );
1666
1900
 
@@ -1669,6 +1903,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1669
1903
  EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
1670
1904
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1671
1905
  await unsubscribeNotifications(deviceId);
1906
+ armIdleDisconnect(deviceId);
1672
1907
  }
1673
1908
  );
1674
1909
 
@@ -1714,15 +1949,23 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1714
1949
  webContents.on('destroyed', () => {
1715
1950
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1716
1951
 
1717
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1952
+ // Physically disconnect too: the main process can outlive this window
1953
+ // (tray) and a held link would block the device for every other host.
1718
1954
  const deviceIds = Array.from(connectedDevices.keys());
1719
1955
  deviceIds.forEach(deviceId => {
1956
+ const peripheral = connectedDevices.get(deviceId);
1720
1957
  cleanupDevice(deviceId, undefined, {
1721
1958
  cleanupConnection: true,
1722
1959
  sendDisconnectEvent: false,
1723
1960
  cancelOperations: true,
1724
1961
  reason: 'app-quit',
1725
1962
  });
1963
+ if (peripheral && peripheral.state === 'connected') {
1964
+ peripheral.removeAllListeners('disconnect');
1965
+ peripheral.disconnect(() => {
1966
+ safeLog(logger, 'info', `Disconnected ${deviceId} on window teardown`);
1967
+ });
1968
+ }
1726
1969
  });
1727
1970
 
1728
1971
  // 2. Stop scanning
@@ -8,6 +8,8 @@ export interface NobleBleAPI {
8
8
  enumerate: () => Promise<{ id: string; name: string }[]>;
9
9
  getDevice: (uuid: string) => Promise<{ id: string; name: string } | null>;
10
10
  connect: (uuid: string) => Promise<void>;
11
+ /** Logical end-of-operation (keep-alive): starts the idle-disconnect countdown. */
12
+ release?: (uuid: string) => Promise<void>;
11
13
  disconnect: (uuid: string) => Promise<void>;
12
14
  subscribe: (uuid: string) => Promise<void>;
13
15
  unsubscribe: (uuid: string) => Promise<void>;
@@ -30,6 +30,15 @@ export interface NobleModule {
30
30
  stopScanning(callback?: () => void): void;
31
31
  on(event: 'stateChange', listener: (state: string) => void): void;
32
32
  on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
33
+ /**
34
+ * Connect by id/address with NO scan. Both native backends support this and
35
+ * emit a `discover` for the peripheral as a side effect (Windows synthesizes
36
+ * one for an unknown address; macOS resolves via
37
+ * retrievePeripheralsWithIdentifiers). Optional so an old noble can omit it.
38
+ * CAUTION: on macOS this never resolves for an id CoreBluetooth cannot
39
+ * retrieve — callers must time-box it.
40
+ */
41
+ connectAsync?(idOrAddress: string): Promise<Peripheral | undefined>;
33
42
  removeListener(event: 'stateChange', listener: (state: string) => void): void;
34
43
  removeListener(event: 'discover', listener: (peripheral: Peripheral) => void): void;
35
44
  }