@onekeyfe/hd-transport-electron 1.2.0-alpha.98 → 1.2.0

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-ce551d9a.js'); });
35
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-56d2eab3.js'); });
36
36
  setupNobleBleHandlers(webContents);
37
37
  });
38
38
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { WebContents } from 'electron';
2
2
  import { Characteristic, Peripheral } from '@stoprocent/noble';
3
3
  import { OneKeyDeviceInfoBase } from '@onekeyfe/hd-transport';
4
+ import { EBleDisconnectReason } from '@onekeyfe/hd-shared';
4
5
 
5
6
  interface DeviceInfo extends OneKeyDeviceInfoBase {
6
7
  id: string;
@@ -24,6 +25,7 @@ interface NobleModule {
24
25
  interface Logger {
25
26
  info(message: string, ...args: any[]): void;
26
27
  debug(message: string, ...args: any[]): void;
28
+ warn(message: string, ...args: any[]): void;
27
29
  error(message: string, ...args: any[]): void;
28
30
  }
29
31
 
@@ -31,6 +33,7 @@ interface Logger {
31
33
  * Desktop API types for Electron preload script
32
34
  * These types define the core interface for Noble BLE communication
33
35
  */
36
+
34
37
  interface NobleBleWriteOptions {
35
38
  pacingDelayMs?: number;
36
39
  }
@@ -55,9 +58,16 @@ interface NobleBleAPI {
55
58
  id: string;
56
59
  mtu: number;
57
60
  }) => void) => () => void;
61
+ /**
62
+ * Fires whenever a BLE link drops. `reason` distinguishes a real peripheral
63
+ * drop from the main process freeing an idle link on its keep-alive timer;
64
+ * it is optional so an older host bridge that omits it still type-checks
65
+ * (absent is treated as a real drop by consumers).
66
+ */
58
67
  onDeviceDisconnected: (callback: (device: {
59
68
  id: string;
60
69
  name: string;
70
+ reason?: EBleDisconnectReason;
61
71
  }) => void) => () => void;
62
72
  checkAvailability: () => Promise<{
63
73
  available: boolean;
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-6da32f94.js');
5
+ var index = require('./index-a518d449.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-6da32f94.js');
3
+ var index = require('./index-a518d449.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -116,7 +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;
119
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
120
+ const BLE_IDLE_DISCONNECT_MS = 20000;
120
121
  const BLE_BUSY_BACKSTOP_MS = 10 * 60000;
121
122
  const BLE_PACKET_SIZE_FALLBACK = 192;
122
123
  const BLE_PACKET_SIZE_MAXIMUM = 244;
@@ -135,7 +136,7 @@ function isOneKeyPeripheral(peripheral) {
135
136
  var _a, _b;
136
137
  const serviceUuids = (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.serviceUuids;
137
138
  const localName = (_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName;
138
- if (!(localName === null || localName === void 0 ? void 0 : localName.trim()) || hdShared.isPro2FindMyAdvertisementName(localName)) {
139
+ if (!(localName === null || localName === void 0 ? void 0 : localName.trim())) {
139
140
  return false;
140
141
  }
141
142
  return (hdShared.hasOnekeyCommunicationService(serviceUuids) &&
@@ -242,6 +243,7 @@ function initializeNoble() {
242
243
  try {
243
244
  noble = require('@stoprocent/noble');
244
245
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Noble library loaded');
246
+ setupPersistentStateListener();
245
247
  yield new Promise((resolve, reject) => {
246
248
  if (!noble) {
247
249
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not initialized'));
@@ -251,7 +253,6 @@ function initializeNoble() {
251
253
  resolve();
252
254
  return;
253
255
  }
254
- setupPersistentStateListener();
255
256
  const timeout = setTimeout(() => {
256
257
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout'));
257
258
  }, BLUETOOTH_INIT_TIMEOUT);
@@ -336,11 +337,19 @@ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle
336
337
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
337
338
  const peripheral = connectedDevices.get(deviceId);
338
339
  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
+ const pending = unsubscribeNotifications(deviceId)
341
+ .catch(error => {
342
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
343
+ deviceId,
344
+ error: error instanceof Error ? error.message : String(error),
345
+ });
346
+ })
347
+ .then(() => disconnectDevice(deviceId))
340
348
  .then(() => {
341
349
  broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
342
350
  id: deviceId,
343
351
  name: deviceName,
352
+ reason: hdShared.EBleDisconnectReason.IdleKeepAlive,
344
353
  });
345
354
  })
346
355
  .catch(error => {
@@ -356,7 +365,7 @@ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle
356
365
  }
357
366
  function cleanupDevice(deviceId, webContents, options = {}) {
358
367
  var _a;
359
- const { cleanupConnection = true, cleanupDiscoveredCache = false, sendDisconnectEvent = false, cancelOperations = true, reason = 'unknown', } = options;
368
+ const { cleanupConnection = true, cleanupDiscoveredCache = false, sendDisconnectEvent = false, cancelOperations = true, reason = 'unknown', disconnectReason = hdShared.EBleDisconnectReason.DeviceDisconnected, } = options;
360
369
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Starting device cleanup', {
361
370
  deviceId,
362
371
  reason,
@@ -393,6 +402,7 @@ function cleanupDevice(deviceId, webContents, options = {}) {
393
402
  broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
394
403
  id: deviceId,
395
404
  name: deviceName,
405
+ reason: disconnectReason,
396
406
  });
397
407
  }
398
408
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device cleanup completed', { deviceId, reason });
@@ -407,6 +417,7 @@ function handleDeviceDisconnect(deviceId, webContents) {
407
417
  });
408
418
  cleanupDevice(deviceId, webContents, {
409
419
  cleanupConnection: true,
420
+ cleanupDiscoveredCache: true,
410
421
  sendDisconnectEvent: true,
411
422
  cancelOperations: true,
412
423
  reason: 'auto-disconnect',
@@ -588,18 +599,37 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
588
599
  }
589
600
  });
590
601
  }
602
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
603
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
604
+ const bleNamesById = new Map();
605
+ function rememberBleName(deviceId, name) {
606
+ const trimmed = name === null || name === void 0 ? void 0 : name.trim();
607
+ if (trimmed) {
608
+ bleNamesById.set(deviceId, trimmed);
609
+ }
610
+ }
611
+ function resolveBleName(deviceId) {
612
+ var _a, _b, _c, _d, _e, _f;
613
+ const live = ((_c = (_b = (_a = connectedDevices.get(deviceId)) === null || _a === void 0 ? void 0 : _a.advertisement) === null || _b === void 0 ? void 0 : _b.localName) === null || _c === void 0 ? void 0 : _c.trim()) ||
614
+ ((_f = (_e = (_d = discoveredDevices.get(deviceId)) === null || _d === void 0 ? void 0 : _d.advertisement) === null || _e === void 0 ? void 0 : _e.localName) === null || _f === void 0 ? void 0 : _f.trim());
615
+ return live || bleNamesById.get(deviceId);
616
+ }
617
+ function shouldConnectByIdFirst(deviceId) {
618
+ return hdShared.isPro2FamilyBleName(resolveBleName(deviceId));
619
+ }
591
620
  function handleDeviceDiscovered(peripheral) {
592
- var _a, _b;
621
+ var _a, _b, _c;
593
622
  if (!isOneKeyPeripheral(peripheral)) {
594
623
  return;
595
624
  }
596
625
  const isNewDevice = !discoveredDevices.has(peripheral.id);
597
626
  discoveredDevices.set(peripheral.id, peripheral);
627
+ rememberBleName(peripheral.id, (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName);
598
628
  if (isNewDevice) {
599
629
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] OneKey BLE device discovered', {
600
630
  deviceId: peripheral.id,
601
- name: ((_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device',
602
- serviceUUIDs: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.serviceUuids) || [],
631
+ name: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName) || 'Unknown Device',
632
+ serviceUUIDs: ((_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.serviceUuids) || [],
603
633
  });
604
634
  }
605
635
  }
@@ -625,7 +655,7 @@ function waitForNobleScanStop(nobleInstance) {
625
655
  });
626
656
  });
627
657
  }
628
- function performTargetedScan(targetDeviceId) {
658
+ function performTargetedScan(targetDeviceId, timeoutMs = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS) {
629
659
  return index.__awaiter(this, void 0, void 0, function* () {
630
660
  if (!noble) {
631
661
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
@@ -665,9 +695,9 @@ function performTargetedScan(targetDeviceId) {
665
695
  const timeoutId = setTimeout(() => {
666
696
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
667
697
  finish(null).catch(reject);
668
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
698
+ }, timeoutMs);
669
699
  nobleInstance.on('discover', onDiscover);
670
- nobleInstance.startScanning([], false, (error) => {
700
+ nobleInstance.startScanning([], true, (error) => {
671
701
  if (error) {
672
702
  finish(null, error).catch(reject);
673
703
  return;
@@ -720,7 +750,7 @@ function enumerateDevices() {
720
750
  resolve(devices);
721
751
  }), DEVICE_SCAN_TIMEOUT);
722
752
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Scanning for OneKey BLE devices');
723
- nobleInstance.startScanning([], false, (error) => index.__awaiter(this, void 0, void 0, function* () {
753
+ nobleInstance.startScanning([], true, (error) => index.__awaiter(this, void 0, void 0, function* () {
724
754
  if (error) {
725
755
  yield cleanup();
726
756
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Failed to start scanning:', error);
@@ -800,7 +830,7 @@ function discoverServicesAndCharacteristics(peripheral) {
800
830
  });
801
831
  const discoveryPromise = (() => index.__awaiter(this, void 0, void 0, function* () {
802
832
  const services = yield new Promise((resolve, reject) => {
803
- peripheral.discoverServices([], (error, svc) => {
833
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
804
834
  if (error) {
805
835
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed:', error);
806
836
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, error.message));
@@ -811,7 +841,7 @@ function discoverServicesAndCharacteristics(peripheral) {
811
841
  });
812
842
  });
813
843
  if (!services || services.length === 0) {
814
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No services found');
844
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
815
845
  }
816
846
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] services discovered', {
817
847
  deviceId: peripheral.id,
@@ -928,11 +958,11 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
928
958
  deviceId,
929
959
  peripheralState: peripheral.state,
930
960
  attempt: attemptNumber,
931
- maxRetries: 5,
961
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
932
962
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
933
963
  });
934
964
  if (attemptNumber > 1) {
935
- logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
965
+ logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`);
936
966
  }
937
967
  if (peripheral.state !== 'connected') {
938
968
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, `Device not connected: ${peripheral.state}`);
@@ -941,16 +971,16 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
941
971
  return yield discoverServicesAndCharacteristics(peripheral);
942
972
  }
943
973
  catch (error) {
944
- logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
945
- if (attemptNumber < 5) {
946
- logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
974
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
975
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
976
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
947
977
  }
948
978
  throw error;
949
979
  }
950
980
  }), {
951
- retries: 4,
981
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
952
982
  factor: 1.5,
953
- minTimeout: 1000,
983
+ minTimeout: 500,
954
984
  maxTimeout: 3000,
955
985
  onFailedAttempt: error => {
956
986
  logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -964,23 +994,24 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
964
994
  }
965
995
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
966
996
  return index.__awaiter(this, void 0, void 0, function* () {
967
- setupDisconnectListener(peripheral, deviceId, webContents);
968
997
  try {
969
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
998
+ yield forceReconnectPeripheral(peripheral, deviceId);
970
999
  }
971
- catch (initialError) {
972
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
973
- deviceId,
974
- error: initialError,
975
- });
1000
+ catch (resetError) {
1001
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
976
1002
  }
977
- yield forceReconnectPeripheral(peripheral, deviceId);
978
1003
  setupDisconnectListener(peripheral, deviceId, webContents);
979
1004
  try {
980
1005
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
981
1006
  }
982
- catch (reconnectError) {
983
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
1007
+ catch (discoveryError) {
1008
+ if (peripheral.state === 'connected') {
1009
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan', discoveryError);
1010
+ yield disconnectDevice(deviceId).catch(() => undefined);
1011
+ }
1012
+ else {
1013
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
1014
+ }
984
1015
  return freshScanAndDiscover(deviceId, webContents);
985
1016
  }
986
1017
  });
@@ -1040,7 +1071,7 @@ function tryDirectConnectById(deviceId) {
1040
1071
  });
1041
1072
  }
1042
1073
  function connectDevice(deviceId, webContents) {
1043
- var _a;
1074
+ var _a, _b, _c;
1044
1075
  return index.__awaiter(this, void 0, void 0, function* () {
1045
1076
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
1046
1077
  deviceId,
@@ -1052,34 +1083,47 @@ function connectDevice(deviceId, webContents) {
1052
1083
  });
1053
1084
  let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
1054
1085
  if (!peripheral) {
1055
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1056
1086
  if (!noble) {
1057
1087
  yield initializeNoble();
1058
1088
  }
1059
1089
  if (!noble) {
1060
1090
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
1061
1091
  }
1062
- peripheral = yield tryDirectConnectById(deviceId);
1063
- if (peripheral) {
1064
- discoveredDevices.set(deviceId, peripheral);
1065
- }
1066
- }
1067
- if (!peripheral) {
1068
- try {
1069
- const foundPeripheral = yield performTargetedScan(deviceId);
1070
- if (!foundPeripheral) {
1071
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found even after targeted scan`);
1092
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1093
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Resolving device for cold connect', {
1094
+ deviceId,
1095
+ name: (_b = resolveBleName(deviceId)) !== null && _b !== void 0 ? _b : 'unknown',
1096
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1097
+ });
1098
+ const scanForPeripheral = () => index.__awaiter(this, void 0, void 0, function* () {
1099
+ var _d;
1100
+ try {
1101
+ return (_d = (yield performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS))) !== null && _d !== void 0 ? _d : undefined;
1072
1102
  }
1073
- peripheral = foundPeripheral;
1074
- }
1075
- catch (error) {
1076
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Targeted scan failed:', error);
1077
- throw error;
1103
+ catch (scanError) {
1104
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan failed', {
1105
+ deviceId,
1106
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1107
+ });
1108
+ return undefined;
1109
+ }
1110
+ });
1111
+ const connectById = () => index.__awaiter(this, void 0, void 0, function* () {
1112
+ const found = yield tryDirectConnectById(deviceId);
1113
+ if (found) {
1114
+ discoveredDevices.set(deviceId, found);
1115
+ }
1116
+ return found;
1117
+ });
1118
+ peripheral = byIdFirst ? yield connectById() : yield scanForPeripheral();
1119
+ if (!peripheral) {
1120
+ peripheral = byIdFirst ? yield scanForPeripheral() : yield connectById();
1078
1121
  }
1079
1122
  }
1080
1123
  if (!peripheral) {
1081
1124
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1082
1125
  }
1126
+ rememberBleName(deviceId, (_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.localName);
1083
1127
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connecting to device:', deviceId);
1084
1128
  if (peripheral.state === 'connected') {
1085
1129
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device already connected, skipping connection step');
@@ -1154,9 +1198,9 @@ function connectDevice(deviceId, webContents) {
1154
1198
  }
1155
1199
  catch (setupError) {
1156
1200
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection setup failed:', setupError);
1157
- connectedPeripheral.disconnect(() => {
1158
- reject(setupError);
1159
- });
1201
+ disconnectDevice(deviceId)
1202
+ .catch(() => undefined)
1203
+ .then(() => reject(setupError));
1160
1204
  }
1161
1205
  }));
1162
1206
  });
@@ -1174,11 +1218,15 @@ function disconnectDevice(deviceId) {
1174
1218
  deviceDisconnectListeners.delete(deviceId);
1175
1219
  }
1176
1220
  yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1177
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1221
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1178
1222
  timeoutBehavior: 'resolve',
1179
1223
  });
1224
+ if (peripheral.state === 'connected') {
1225
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1226
+ }
1180
1227
  cleanupDevice(deviceId, undefined, {
1181
1228
  cleanupConnection: true,
1229
+ cleanupDiscoveredCache: true,
1182
1230
  sendDisconnectEvent: false,
1183
1231
  cancelOperations: true,
1184
1232
  reason: 'manual-disconnect',
@@ -1269,9 +1317,14 @@ function subscribeNotifications(deviceId, callback) {
1269
1317
  });
1270
1318
  });
1271
1319
  }
1320
+ const subscribeStartedAt = Date.now();
1272
1321
  try {
1273
1322
  yield rebuildAppSubscription(deviceId, notifyCharacteristic);
1274
1323
  subscribedDevices.set(deviceId, true);
1324
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification subscription active', {
1325
+ deviceId,
1326
+ ms: Date.now() - subscribeStartedAt,
1327
+ });
1275
1328
  }
1276
1329
  finally {
1277
1330
  subscriptionOperations.set(deviceId, 'idle');
@@ -1389,10 +1442,6 @@ function setupNobleBleHandlers(webContents) {
1389
1442
  yield disconnectDevice(deviceId).catch(() => undefined);
1390
1443
  }
1391
1444
  yield stopScanning().catch(() => undefined);
1392
- if (noble && persistentStateListener) {
1393
- noble.removeListener('stateChange', persistentStateListener);
1394
- persistentStateListener = null;
1395
- }
1396
1445
  cleanupNobleListeners();
1397
1446
  discoveredDevices.clear();
1398
1447
  safeLog(logger, 'info', 'Noble BLE cleanup completed');
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAoFhE,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAsvDD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAkMpE"}
@@ -1,3 +1,4 @@
1
+ import type { EBleDisconnectReason } from '@onekeyfe/hd-shared';
1
2
  export interface NobleBleWriteOptions {
2
3
  pacingDelayMs?: number;
3
4
  }
@@ -25,6 +26,7 @@ export interface NobleBleAPI {
25
26
  onDeviceDisconnected: (callback: (device: {
26
27
  id: string;
27
28
  name: string;
29
+ reason?: EBleDisconnectReason;
28
30
  }) => void) => () => void;
29
31
  checkAvailability: () => Promise<{
30
32
  available: boolean;
@@ -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;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"}
1
+ {"version":3,"file":"desktop-api.d.ts","sourceRoot":"","sources":["../../src/types/desktop-api.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAGhE,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;IAOvF,oBAAoB,EAAE,CACpB,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,oBAAoB,CAAA;KAAE,KAAK,IAAI,KACpF,MAAM,IAAI,CAAC;IAChB,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"}
@@ -22,6 +22,7 @@ export interface NobleModule {
22
22
  export interface Logger {
23
23
  info(message: string, ...args: any[]): void;
24
24
  debug(message: string, ...args: any[]): void;
25
+ warn(message: string, ...args: any[]): void;
25
26
  error(message: string, ...args: any[]): void;
26
27
  }
27
28
  export declare function safeLog(logger: Logger | null, level: 'info' | 'debug' | 'error', message: string, ...args: any[]): void;
@@ -1 +1 @@
1
- {"version":3,"file":"noble-extended.d.ts","sourceRoot":"","sources":["../../src/types/noble-extended.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGnE,MAAM,WAAW,UAAW,SAAQ,oBAAoB;IACtD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,cAAc,CAAC;CACxB;AAGD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CACX,YAAY,EAAE,MAAM,EAAE,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GACjC,IAAI,CAAC;IACR,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC1C,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;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,cAAc,CAAC;CACxB;AAGD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CACX,YAAY,EAAE,MAAM,EAAE,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GACjC,IAAI,CAAC;IACR,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC1C,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;IACxE,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC9E,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;CACrF;AAGD,MAAM,WAAW,MAAM;IACrB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC7C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC9C;AAGD,wBAAgB,OAAO,CACrB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,EACjC,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,GAAG,EAAE,GACb,IAAI,CAMN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-electron",
3
- "version": "1.2.0-alpha.98",
3
+ "version": "1.2.0",
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.98",
29
- "@onekeyfe/hd-shared": "1.2.0-alpha.98",
30
- "@onekeyfe/hd-transport": "1.2.0-alpha.98",
28
+ "@onekeyfe/hd-core": "1.2.0",
29
+ "@onekeyfe/hd-shared": "1.2.0",
30
+ "@onekeyfe/hd-transport": "1.2.0",
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": "10f2dcaecec17a629216c2402cc9ab8801987297"
39
+ "gitHead": "cc51df0a415bead21304ad1d8bc92b5312c91344"
40
40
  }
@@ -10,12 +10,12 @@ import type { WebContents } from 'electron';
10
10
 
11
11
  type IpcHandler = (...args: unknown[]) => Promise<unknown> | unknown;
12
12
 
13
- const createPeripheral = (id: string, localName?: string) => ({
13
+ const createPeripheral = (id: string, localName?: string, serviceUuids = ['0001', 'fffd']) => ({
14
14
  id,
15
15
  state: 'disconnected',
16
16
  advertisement: {
17
17
  localName,
18
- serviceUuids: ['0001', 'fffd'],
18
+ serviceUuids,
19
19
  },
20
20
  });
21
21
 
@@ -171,6 +171,7 @@ describe('Electron Noble BLE device discovery', () => {
171
171
  const connectPromise = Promise.resolve(connect(undefined, 'target-device'));
172
172
  await scanStarted;
173
173
 
174
+ expect(noble.startScanning).toHaveBeenCalledWith([], true, expect.any(Function));
174
175
  expect(noble.stopScanning).toHaveBeenCalledTimes(1);
175
176
  expect(peripheral.connect).not.toHaveBeenCalled();
176
177
 
@@ -250,7 +251,7 @@ describe('Electron Noble BLE device discovery', () => {
250
251
  expect(peripheral.disconnect).toHaveBeenCalledTimes(1);
251
252
  });
252
253
 
253
- test('does not enumerate a Pro2 Find My advertisement with the communication service', async () => {
254
+ test('enumerates a Pro2 communication advertisement after Find My changes its name', async () => {
254
255
  jest.useFakeTimers({ doNotFake: ['performance'] });
255
256
 
256
257
  const handlers = new Map<string, IpcHandler>();
@@ -274,8 +275,8 @@ describe('Electron Noble BLE device discovery', () => {
274
275
  noble.state = 'poweredOn';
275
276
  noble.startScanning = jest.fn((_services, _duplicates, callback) => {
276
277
  callback?.();
277
- noble.emit('discover', createPeripheral('find-my-device', 'Pro2 A1B2 - Find My'));
278
- noble.emit('discover', createPeripheral('onekey-device', 'Pro2 A1B2'));
278
+ noble.emit('discover', createPeripheral('find-my-device', 'Pro2 A1B2 - Find My', ['fffd']));
279
+ noble.emit('discover', createPeripheral('onekey-device', 'Pro2 A1B2 - Find My'));
279
280
  resolveScanStarted();
280
281
  });
281
282
  noble.stopScanning = jest.fn(callback => callback?.());
@@ -301,17 +302,18 @@ describe('Electron Noble BLE device discovery', () => {
301
302
 
302
303
  const devicesPromise = Promise.resolve(enumerate());
303
304
  await scanStarted;
305
+ expect(noble.startScanning).toHaveBeenCalledWith([], true, expect.any(Function));
304
306
  jest.advanceTimersByTime(5000);
305
307
 
306
308
  await expect(devicesPromise).resolves.toEqual([
307
309
  expect.objectContaining({
308
310
  id: 'onekey-device',
309
- name: 'Pro2 A1B2',
311
+ name: 'Pro2 A1B2 - Find My',
310
312
  }),
311
313
  ]);
312
314
  });
313
315
 
314
- test('does not enumerate a Find My peripheral first discovered without a name', async () => {
316
+ test('accepts a communication peripheral after its scan response gains a name', async () => {
315
317
  jest.useFakeTimers({ doNotFake: ['performance'] });
316
318
 
317
319
  const handlers = new Map<string, IpcHandler>();
@@ -368,6 +370,10 @@ describe('Electron Noble BLE device discovery', () => {
368
370
  jest.advanceTimersByTime(5000);
369
371
 
370
372
  await expect(devicesPromise).resolves.toEqual([
373
+ expect.objectContaining({
374
+ id: 'find-my-device',
375
+ name: 'Pro2 A1B2 - Find My',
376
+ }),
371
377
  expect.objectContaining({
372
378
  id: 'onekey-device',
373
379
  name: 'Pro2 A1B2',
@@ -6,6 +6,7 @@
6
6
  /* eslint-disable @typescript-eslint/no-var-requires, import/no-extraneous-dependencies */
7
7
 
8
8
  import {
9
+ EBleDisconnectReason,
9
10
  EOneKeyBleMessageKeys,
10
11
  ERRORS,
11
12
  HardwareErrorCode,
@@ -15,7 +16,7 @@ import {
15
16
  createKnownBleUuidAliases,
16
17
  hasOnekeyCommunicationService,
17
18
  isOnekeyBluetoothDevice,
18
- isPro2FindMyAdvertisementName,
19
+ isPro2FamilyBleName,
19
20
  matchesKnownBleUuid,
20
21
  wait,
21
22
  } from '@onekeyfe/hd-shared';
@@ -90,8 +91,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
91
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
92
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
93
  const BLE_CLEANUP_TIMEOUT = 250;
94
+ // A physical teardown must actually reach the OS before the device can return
95
+ // to a clean state; 250ms routinely declared success while CoreBluetooth was
96
+ // still disconnecting, leaving no trace when the teardown never completed.
97
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
93
98
  // Renderer release is logical only; this timer physically frees the device.
94
- const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
99
+ // Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
100
+ // after sitting idle for minutes (field logs 2026-08-19: every deaf window
101
+ // followed a 3-minute-idle disconnect, while disconnects right after traffic
102
+ // have never produced one across 6.5.0's per-call teardown history). A hot
103
+ // disconnect ~20s after the last operation stays inside the proven-safe
104
+ // pattern and also shrinks the window in which phones cannot see the device.
105
+ const BLE_IDLE_DISCONNECT_MS = 20_000;
95
106
  // Ceiling while a call is in flight: no outstanding write, but not forever.
96
107
  const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
97
108
 
@@ -115,9 +126,7 @@ function isOneKeyPeripheral(peripheral: Peripheral) {
115
126
  const serviceUuids = peripheral.advertisement?.serviceUuids;
116
127
  const localName = peripheral.advertisement?.localName;
117
128
 
118
- // Noble localName is the current advertisement name, so reject the Pro2
119
- // Find My endpoint before the communication-service fast path accepts it.
120
- if (!localName?.trim() || isPro2FindMyAdvertisementName(localName)) {
129
+ if (!localName?.trim()) {
121
130
  return false;
122
131
  }
123
132
 
@@ -259,6 +268,11 @@ async function initializeNoble(): Promise<void> {
259
268
  noble = require('@stoprocent/noble') as NobleModule;
260
269
  logger?.info('[NobleBLE] Noble library loaded');
261
270
 
271
+ // Register the process-lifetime state listener before any early return:
272
+ // the poweredOn fast path below would otherwise skip it for the whole
273
+ // session, leaving poweredOff cache/state reconciliation dead.
274
+ setupPersistentStateListener();
275
+
262
276
  // Wait for Bluetooth to be ready
263
277
  await new Promise<void>((resolve, reject) => {
264
278
  if (!noble) {
@@ -271,9 +285,6 @@ async function initializeNoble(): Promise<void> {
271
285
  return;
272
286
  }
273
287
 
274
- // Setup persistent state listener before initialization
275
- setupPersistentStateListener();
276
-
277
288
  const timeout = setTimeout(() => {
278
289
  reject(
279
290
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
@@ -339,6 +350,12 @@ interface DeviceCleanupOptions {
339
350
  cleanupDiscoveredCache?: boolean;
340
351
  /** Whether to send disconnect event */
341
352
  sendDisconnectEvent?: boolean;
353
+ /**
354
+ * Why the link went down, forwarded to renderers on BLE_DEVICE_DISCONNECTED.
355
+ * Defaults to a real peripheral drop; the keep-alive path overrides it so
356
+ * consumers can tell an internal idle release from a device that left.
357
+ */
358
+ disconnectReason?: EBleDisconnectReason;
342
359
  /** Whether to cancel ongoing operations */
343
360
  cancelOperations?: boolean;
344
361
  /** Cleanup reason (for logging) */
@@ -394,12 +411,31 @@ function armIdleDisconnect(
394
411
  logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
395
412
  const peripheral = connectedDevices.get(deviceId);
396
413
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
397
- const pending = disconnectDevice(deviceId)
414
+ // Unsubscribe CCCD before dropping the link, matching every other
415
+ // teardown path: the 1S leaves its notify session half-open when the
416
+ // link drops without an unsubscribe and then ignores application
417
+ // protocol traffic on NEW links for tens of minutes (field log
418
+ // 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
419
+ // 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
420
+ // instantly).
421
+ const pending = unsubscribeNotifications(deviceId)
422
+ .catch(error => {
423
+ logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
424
+ deviceId,
425
+ error: error instanceof Error ? error.message : String(error),
426
+ });
427
+ })
428
+ .then(() => disconnectDevice(deviceId))
398
429
  .then(() => {
399
430
  // A call is still in flight here; it must reject, not hang.
431
+ // Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
432
+ // we reclaimed the link ourselves and have no evidence the device
433
+ // left, so consumers must not mark it disconnected. The in-flight
434
+ // call still fails, via the renderer dropping its device state.
400
435
  broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
401
436
  id: deviceId,
402
437
  name: deviceName,
438
+ reason: EBleDisconnectReason.IdleKeepAlive,
403
439
  });
404
440
  })
405
441
  .catch(error => {
@@ -429,6 +465,7 @@ function cleanupDevice(
429
465
  sendDisconnectEvent = false,
430
466
  cancelOperations = true,
431
467
  reason = 'unknown',
468
+ disconnectReason = EBleDisconnectReason.DeviceDisconnected,
432
469
  } = options;
433
470
 
434
471
  logger?.info('[NobleBLE] Starting device cleanup', {
@@ -478,6 +515,7 @@ function cleanupDevice(
478
515
  broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
479
516
  id: deviceId,
480
517
  name: deviceName,
518
+ reason: disconnectReason,
481
519
  });
482
520
  }
483
521
 
@@ -497,6 +535,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
497
535
 
498
536
  cleanupDevice(deviceId, webContents, {
499
537
  cleanupConnection: true,
538
+ // Same stale-object hazard as manual disconnect: an externally dropped
539
+ // link invalidates the cached peripheral for the next reconnect.
540
+ cleanupDiscoveredCache: true,
500
541
  sendDisconnectEvent: true,
501
542
  cancelOperations: true,
502
543
  reason: 'auto-disconnect',
@@ -750,6 +791,44 @@ async function transmitHexDataToDevice(
750
791
  }
751
792
 
752
793
  // Handle discovered device (for general enumeration only)
794
+ // A scan that finds nothing costs this much before the direct-connect
795
+ // fallback runs. Every advertisement in the 6.5.0 control logs arrived within
796
+ // 631ms, so this leaves ~2x headroom while keeping a miss cheap.
797
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
798
+
799
+ // Attempts before a link is written off as unable to serve the OneKey service.
800
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
801
+
802
+ // Advertised names, kept for the life of the process: device caches are purged
803
+ // on every disconnect, but the family a device belongs to never changes and
804
+ // decides which connection strategy is safe for it.
805
+ const bleNamesById = new Map<string, string>();
806
+
807
+ function rememberBleName(deviceId: string, name?: string | null): void {
808
+ const trimmed = name?.trim();
809
+ if (trimmed) {
810
+ bleNamesById.set(deviceId, trimmed);
811
+ }
812
+ }
813
+
814
+ function resolveBleName(deviceId: string): string | undefined {
815
+ const live =
816
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
817
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
818
+ return live || bleNamesById.get(deviceId);
819
+ }
820
+
821
+ /**
822
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
823
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
824
+ * match. Everything else — Classic family, and any device whose name is not
825
+ * known yet — scans first, because connecting by id can resolve a session the
826
+ * device no longer serves (see setupConnectionAndDiscoverServices).
827
+ */
828
+ function shouldConnectByIdFirst(deviceId: string): boolean {
829
+ return isPro2FamilyBleName(resolveBleName(deviceId));
830
+ }
831
+
753
832
  function handleDeviceDiscovered(peripheral: Peripheral): void {
754
833
  // Only process OneKey candidates for general discovery. Avoid logging every
755
834
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -759,6 +838,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
759
838
 
760
839
  const isNewDevice = !discoveredDevices.has(peripheral.id);
761
840
  discoveredDevices.set(peripheral.id, peripheral);
841
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
762
842
  if (isNewDevice) {
763
843
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
764
844
  deviceId: peripheral.id,
@@ -793,7 +873,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
793
873
 
794
874
  // Perform targeted scan for a specific device ID
795
875
  // Uses self-contained local listener pattern - no global state needed
796
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
876
+ async function performTargetedScan(
877
+ targetDeviceId: string,
878
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
879
+ ): Promise<Peripheral | null> {
797
880
  if (!noble) {
798
881
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
799
882
  }
@@ -838,13 +921,14 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
838
921
  const timeoutId = setTimeout(() => {
839
922
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
840
923
  finish(null).catch(reject);
841
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
924
+ }, timeoutMs);
842
925
 
843
926
  // Add local listener for this scan
844
927
  nobleInstance.on('discover', onDiscover);
845
928
 
846
- // Start scanning no service UUID filter (Pro2 may use different service UUID)
847
- nobleInstance.startScanning([], false, (error?: Error) => {
929
+ // Allow repeated advertisements so a service-only packet can be followed by
930
+ // the named scan response needed to validate the target peripheral.
931
+ nobleInstance.startScanning([], true, (error?: Error) => {
848
932
  if (error) {
849
933
  finish(null, error).catch(reject);
850
934
  return;
@@ -915,9 +999,10 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
915
999
  }, DEVICE_SCAN_TIMEOUT);
916
1000
 
917
1001
  // Start scanning without a service UUID filter so Pro2 advertisements with
918
- // short vendor UUIDs can be found, but only OneKey candidates are logged/returned.
1002
+ // short vendor UUIDs can be found. Repeated advertisements are required when
1003
+ // the local name arrives in a later scan response; discoveredDevices handles deduplication.
919
1004
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
920
- nobleInstance.startScanning([], false, async (error?: Error) => {
1005
+ nobleInstance.startScanning([], true, async (error?: Error) => {
921
1006
  if (error) {
922
1007
  await cleanup();
923
1008
  logger?.error('[NobleBLE] Failed to start scanning:', error);
@@ -1038,9 +1123,17 @@ async function discoverServicesAndCharacteristics(
1038
1123
 
1039
1124
  // Main discovery logic as async function
1040
1125
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1041
- // Step 1: Discover ALL services (no filter Pro2 may use different service UUID)
1126
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1127
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1128
+ // a device whose stack still advertises and accepts links but no longer
1129
+ // serves its application layer still looks healthy — the link comes up, the
1130
+ // cached services resolve, the protocol frame goes out and nothing ever
1131
+ // answers. A targeted query returns nothing in that state, which is what
1132
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1133
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1134
+ // same service UUID, and the selection below only ever accepts that one.
1042
1135
  const services = await new Promise<Service[]>((resolve, reject) => {
1043
- peripheral.discoverServices([], (error, svc) => {
1136
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1044
1137
  if (error) {
1045
1138
  logger?.error('[NobleBLE] Service discovery failed:', error);
1046
1139
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1051,7 +1144,7 @@ async function discoverServicesAndCharacteristics(
1051
1144
  });
1052
1145
 
1053
1146
  if (!services || services.length === 0) {
1054
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1147
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1055
1148
  }
1056
1149
 
1057
1150
  logger?.debug('[NobleBLE] services discovered', {
@@ -1235,12 +1328,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
1235
1328
  deviceId,
1236
1329
  peripheralState: peripheral.state,
1237
1330
  attempt: attemptNumber,
1238
- maxRetries: 5,
1331
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
1239
1332
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
1240
1333
  });
1241
1334
 
1242
1335
  if (attemptNumber > 1) {
1243
- logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1336
+ logger?.info(
1337
+ `[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
1338
+ );
1244
1339
  }
1245
1340
 
1246
1341
  // Verify connection state before attempting service discovery
@@ -1254,20 +1349,32 @@ async function discoverServicesAndCharacteristicsWithRetry(
1254
1349
  try {
1255
1350
  return await discoverServicesAndCharacteristics(peripheral);
1256
1351
  } catch (error) {
1257
- logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1352
+ logger?.error(
1353
+ `[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1354
+ );
1258
1355
 
1259
- if (attemptNumber < 5) {
1260
- logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1356
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1357
+ logger?.error(
1358
+ `[NobleBLE] Will retry service discovery (attempt ${
1359
+ attemptNumber + 1
1360
+ }/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1361
+ );
1261
1362
  }
1262
1363
 
1263
1364
  throw error; // p-retry will handle the retry logic
1264
1365
  }
1265
1366
  },
1266
1367
  {
1267
- retries: 4, // Total 5 attempts (initial + 4 retries)
1268
- factor: 1.5, // Exponential backoff: 1000ms 1500ms 2250ms 3000ms
1269
- minTimeout: 1000, // Start with 1 second delay
1270
- maxTimeout: 3000, // Maximum 3 seconds delay
1368
+ // One retry, not four. An empty result here is the signature of a device
1369
+ // whose stack no longer serves its application layer, and retrying the
1370
+ // same link has never recovered it — every observed run failed all the
1371
+ // way through. The recovery is the teardown and cold reconnect the caller
1372
+ // performs afterwards, so reaching it sooner is what shortens the wait.
1373
+ // The single retry stays for a genuinely transient miss.
1374
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
1375
+ factor: 1.5,
1376
+ minTimeout: 500,
1377
+ maxTimeout: 3000,
1271
1378
  onFailedAttempt: error => {
1272
1379
  // This runs after each failed attempt
1273
1380
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -1292,26 +1399,45 @@ async function setupConnectionAndDiscoverServices(
1292
1399
  deviceId: string,
1293
1400
  webContents: WebContents
1294
1401
  ): Promise<CharacteristicPair> {
1295
- setupDisconnectListener(peripheral, deviceId, webContents);
1296
-
1402
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1403
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1404
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1405
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1406
+ // reset entirely for this device, because discovery always succeeds: the link
1407
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1408
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1409
+ // link with an intact subscription returns before reaching here, so reuse
1410
+ // inside a workflow is unaffected.
1297
1411
  try {
1298
- return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1299
- } catch (initialError) {
1300
- logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1301
- deviceId,
1302
- error: initialError,
1303
- });
1412
+ await forceReconnectPeripheral(peripheral, deviceId);
1413
+ } catch (resetError) {
1414
+ // A failed reset must not abort the attempt: discovery on the existing
1415
+ // link, then the fresh-scan fallback, still have a chance to recover.
1416
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1304
1417
  }
1305
-
1306
- await forceReconnectPeripheral(peripheral, deviceId);
1307
1418
  setupDisconnectListener(peripheral, deviceId, webContents);
1419
+
1308
1420
  try {
1309
1421
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1310
- } catch (reconnectError) {
1311
- logger?.error(
1312
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1313
- reconnectError
1314
- );
1422
+ } catch (discoveryError) {
1423
+ // A connected peripheral does not advertise, so the fresh scan below would
1424
+ // only run out its timeout. Drop the link first, then rescan: the cold
1425
+ // reconnect is what recovers a device whose stack stopped serving its
1426
+ // application layer. Recover here rather than throwing to the caller —
1427
+ // one acquire pays ~2s for the rescan, while a thrown error costs the
1428
+ // renderer a full retry round trip.
1429
+ if (peripheral.state === 'connected') {
1430
+ logger?.error(
1431
+ '[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan',
1432
+ discoveryError
1433
+ );
1434
+ await disconnectDevice(deviceId).catch(() => undefined);
1435
+ } else {
1436
+ logger?.error(
1437
+ '[NobleBLE] Service discovery failed, attempting fresh scan...',
1438
+ discoveryError
1439
+ );
1440
+ }
1315
1441
  return freshScanAndDiscover(deviceId, webContents);
1316
1442
  }
1317
1443
  }
@@ -1400,10 +1526,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1400
1526
  // enumerate clears the discovery map; a kept-alive link outlives it.
1401
1527
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1402
1528
 
1403
- // If device not discovered, try a targeted scan for this specific device
1404
1529
  if (!peripheral) {
1405
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1406
-
1407
1530
  // Initialize Noble if not already done
1408
1531
  if (!noble) {
1409
1532
  await initializeNoble();
@@ -1413,27 +1536,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1536
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1414
1537
  }
1415
1538
 
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
- }
1539
+ // A live advertisement proves the device holds no link and will open a
1540
+ // fresh session; connecting by id resolves through the OS cache instead
1541
+ // and can hand back a session the device no longer serves (see
1542
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1543
+ // two when the device does advertise: ~96ms median against ~784ms for
1544
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1545
+ // because they are unaffected and may advertise under an unmatchable Find
1546
+ // My name.
1547
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1548
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1549
+ deviceId,
1550
+ name: resolveBleName(deviceId) ?? 'unknown',
1551
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1552
+ });
1422
1553
 
1423
- if (!peripheral) {
1424
- // Perform a targeted scan to find the specific device
1425
- try {
1426
- const foundPeripheral = await performTargetedScan(deviceId);
1427
- if (!foundPeripheral) {
1428
- throw ERRORS.TypedError(
1429
- HardwareErrorCode.DeviceNotFound,
1430
- `Device ${deviceId} not found even after targeted scan`
1431
- );
1554
+ const scanForPeripheral = async () => {
1555
+ try {
1556
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1557
+ } catch (scanError) {
1558
+ logger?.info('[NobleBLE] Targeted scan failed', {
1559
+ deviceId,
1560
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1561
+ });
1562
+ return undefined;
1563
+ }
1564
+ };
1565
+
1566
+ const connectById = async () => {
1567
+ const found = await tryDirectConnectById(deviceId);
1568
+ if (found) {
1569
+ discoveredDevices.set(deviceId, found);
1432
1570
  }
1433
- peripheral = foundPeripheral;
1434
- } catch (error) {
1435
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1436
- throw error;
1571
+ return found;
1572
+ };
1573
+
1574
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1575
+ if (!peripheral) {
1576
+ // The preferred route came up empty: not advertising (held elsewhere, or
1577
+ // silent), or not reachable by id. Try the other one before giving up.
1578
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1437
1579
  }
1438
1580
  }
1439
1581
 
@@ -1442,6 +1584,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1442
1584
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1443
1585
  }
1444
1586
 
1587
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1588
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1589
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1590
+
1445
1591
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1446
1592
 
1447
1593
  // Check if device is already connected
@@ -1562,9 +1708,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1562
1708
  resolve();
1563
1709
  } catch (setupError) {
1564
1710
  logger?.error('[NobleBLE] Connection setup failed:', setupError);
1565
- connectedPeripheral.disconnect(() => {
1566
- reject(setupError);
1567
- });
1711
+ // Never reject from inside a raw disconnect callback: noble only fires
1712
+ // it on a real 'disconnect' event, so a peripheral that is already
1713
+ // down leaves this promise — and the renderer acquire awaiting it —
1714
+ // pending forever. disconnectDevice always settles (it no-ops on an
1715
+ // unknown peripheral and caps the confirm wait).
1716
+ disconnectDevice(deviceId)
1717
+ .catch(() => undefined)
1718
+ .then(() => reject(setupError));
1568
1719
  }
1569
1720
  });
1570
1721
  });
@@ -1584,11 +1735,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1584
1735
  }
1585
1736
 
1586
1737
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1587
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1738
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1588
1739
  timeoutBehavior: 'resolve',
1589
1740
  });
1741
+ if (peripheral.state === 'connected') {
1742
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1743
+ }
1590
1744
  cleanupDevice(deviceId, undefined, {
1591
1745
  cleanupConnection: true,
1746
+ // macOS returns zero GATT services when a previously-disconnected
1747
+ // peripheral object is reconnected; drop the discovery cache so the next
1748
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1749
+ cleanupDiscoveredCache: true,
1592
1750
  sendDisconnectEvent: false,
1593
1751
  cancelOperations: true,
1594
1752
  reason: 'manual-disconnect',
@@ -1729,9 +1887,14 @@ async function subscribeNotifications(
1729
1887
  });
1730
1888
  }
1731
1889
 
1890
+ const subscribeStartedAt = Date.now();
1732
1891
  try {
1733
1892
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
1734
1893
  subscribedDevices.set(deviceId, true);
1894
+ logger?.info('[NobleBLE] Notification subscription active', {
1895
+ deviceId,
1896
+ ms: Date.now() - subscribeStartedAt,
1897
+ });
1735
1898
  } finally {
1736
1899
  // 🔒 CRITICAL: Always clear operation state (even on error)
1737
1900
  subscriptionOperations.set(deviceId, 'idle');
@@ -1914,10 +2077,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1914
2077
  }
1915
2078
 
1916
2079
  await stopScanning().catch(() => undefined);
1917
- if (noble && persistentStateListener) {
1918
- noble.removeListener('stateChange', persistentStateListener);
1919
- persistentStateListener = null;
1920
- }
2080
+ // persistentStateListener is process-lifetime, NOT per-window: this
2081
+ // destroy handler also fires on a renderer soft restart, and removing
2082
+ // the listener here permanently killed poweredOff/poweredOn
2083
+ // reconciliation for the rest of the process (nothing re-registers it:
2084
+ // initializeNoble early-returns once noble is loaded). The null-guard
2085
+ // in setupPersistentStateListener keeps it deduped to one instance.
1921
2086
  cleanupNobleListeners();
1922
2087
  discoveredDevices.clear();
1923
2088
  safeLog(logger, 'info', 'Noble BLE cleanup completed');
@@ -3,6 +3,8 @@
3
3
  * These types define the core interface for Noble BLE communication
4
4
  */
5
5
 
6
+ import type { EBleDisconnectReason } from '@onekeyfe/hd-shared';
7
+
6
8
  // Noble BLE API interface - core BLE functionality
7
9
  export interface NobleBleWriteOptions {
8
10
  pacingDelayMs?: number;
@@ -21,7 +23,15 @@ export interface NobleBleAPI {
21
23
  write: (uuid: string, data: string, options?: NobleBleWriteOptions) => Promise<void>;
22
24
  onNotification: (callback: (deviceId: string, data: string) => void) => () => void;
23
25
  onMtuChanged?: (callback: (device: { id: string; mtu: number }) => void) => () => void;
24
- onDeviceDisconnected: (callback: (device: { id: string; name: string }) => void) => () => void;
26
+ /**
27
+ * Fires whenever a BLE link drops. `reason` distinguishes a real peripheral
28
+ * drop from the main process freeing an idle link on its keep-alive timer;
29
+ * it is optional so an older host bridge that omits it still type-checks
30
+ * (absent is treated as a real drop by consumers).
31
+ */
32
+ onDeviceDisconnected: (
33
+ callback: (device: { id: string; name: string; reason?: EBleDisconnectReason }) => void
34
+ ) => () => void;
25
35
  checkAvailability: () => Promise<{
26
36
  available: boolean;
27
37
  state: string;
@@ -39,6 +39,7 @@ export interface NobleModule {
39
39
  export interface Logger {
40
40
  info(message: string, ...args: any[]): void;
41
41
  debug(message: string, ...args: any[]): void;
42
+ warn(message: string, ...args: any[]): void;
42
43
  error(message: string, ...args: any[]): void;
43
44
  }
44
45