@onekeyfe/hd-transport-electron 1.2.0-alpha.15 → 1.2.0-alpha.151

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.
@@ -9,17 +9,28 @@ import {
9
9
  EOneKeyBleMessageKeys,
10
10
  ERRORS,
11
11
  HardwareErrorCode,
12
+ ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
12
13
  ONEKEY_SERVICE_UUID,
13
- isOnekeyDevice,
14
+ ONEKEY_WRITE_CHARACTERISTIC_UUID,
15
+ createKnownBleUuidAliases,
16
+ hasOnekeyCommunicationService,
17
+ isOnekeyBluetoothDevice,
18
+ matchesKnownBleUuid,
14
19
  wait,
15
20
  } from '@onekeyfe/hd-shared';
16
21
  import pRetry from 'p-retry';
17
22
 
23
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
18
24
  import { safeLog } from './types/noble-extended';
19
- import { softRefreshSubscription } from './ble-ops';
25
+ import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
26
+ import {
27
+ NOBLE_BLE_CONNECTION_TIMEOUT_MS,
28
+ NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
29
+ } from './noble-ble-timeouts';
20
30
 
21
- import type { IpcMainInvokeEvent, WebContents } from 'electron';
31
+ import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
22
32
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
33
+ import type { NobleBleWriteOptions } from './types/desktop-api';
23
34
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
24
35
 
25
36
  // Noble will be dynamically imported to avoid bundling issues
@@ -39,6 +50,7 @@ const bluetoothState: {
39
50
 
40
51
  // Global persistent state listener for app layer
41
52
  let persistentStateListener: ((state: string) => void) | null = null;
53
+ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
42
54
 
43
55
  // Device cache and connection state
44
56
  const discoveredDevices = new Map<string, Peripheral>();
@@ -50,6 +62,14 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
50
62
 
51
63
  // 🔒 Subscription operation state tracking to prevent race conditions
52
64
  const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
65
+ const deviceDisconnectListeners = new Map<
66
+ string,
67
+ { peripheral: Peripheral; listener: () => void }
68
+ >();
69
+ const deviceMtuListeners = new Map<
70
+ string,
71
+ { peripheral: Peripheral; listener: (mtu: number) => void }
72
+ >();
53
73
 
54
74
  // Windows-only response watchdog state moved to utils/windows-ble-recovery
55
75
 
@@ -59,57 +79,53 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
59
79
 
60
80
  // Service UUIDs to scan for - using constants from hd-shared
61
81
  const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
62
- const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
63
-
64
- // Pre-normalized characteristic identifiers for fast comparison
65
- const NORMALIZED_WRITE_UUID = '0002';
66
- const NORMALIZED_NOTIFY_UUID = '0003';
82
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
83
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
84
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
67
85
 
68
86
  // Timeout and interval constants
69
87
  const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
70
- const DEVICE_SCAN_TIMEOUT = 8000; // 8 seconds for device scanning (Pro2 has longer advertising interval)
71
- const FAST_SCAN_TIMEOUT = 8000; // 8 seconds for targeted scanning (Pro2 has longer advertising interval)
88
+ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
72
89
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
73
- const CONNECTION_TIMEOUT = 8000; // 8 seconds for device connection (BLE reconnect after release can be slow)
74
90
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
91
+ const BLE_CLEANUP_TIMEOUT = 250;
92
+ // Renderer release is logical only; this timer physically frees the device.
93
+ const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
94
+ // Ceiling while a call is in flight: no outstanding write, but not forever.
95
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
75
96
 
76
97
  // Write-related constants
77
- const BLE_PACKET_SIZE = 192;
78
- const UNIFIED_WRITE_DELAY = 5;
98
+ const BLE_PACKET_SIZE_FALLBACK = 192;
99
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
100
+ const DEFAULT_WRITE_PACING_DELAY_MS = 5;
79
101
  const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 } as const;
80
102
  const IS_WINDOWS = process.platform === 'win32';
81
103
  const ABORTABLE_WRITE_ERROR_PATTERNS = [
82
104
  /status:\s*3/i, // Windows pairing cancelled / GATT write failed
83
105
  ];
84
106
 
85
- function getBleUuidKey(uuid?: string | null) {
86
- const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
87
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
107
+ export function resolveNobleBleWritePacingDelay(options?: NobleBleWriteOptions) {
108
+ return typeof options?.pacingDelayMs === 'number' && Number.isFinite(options.pacingDelayMs)
109
+ ? Math.min(Math.max(Math.floor(options.pacingDelayMs), 0), 1000)
110
+ : DEFAULT_WRITE_PACING_DELAY_MS;
88
111
  }
89
112
 
90
- const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
91
- ...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
92
- '0001',
93
- ]);
94
-
95
- function isGenericBleService(uuid?: string | null) {
96
- return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
97
- }
113
+ function isOneKeyPeripheral(peripheral: Peripheral) {
114
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
115
+ const localName = peripheral.advertisement?.localName;
98
116
 
99
- function hasOneKeyAdvertisementService(peripheral: Peripheral) {
100
- const serviceUuids = peripheral.advertisement?.serviceUuids ?? [];
101
- return serviceUuids.some(uuid => {
102
- const uuidKey = getBleUuidKey(uuid);
103
- return (
104
- NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
105
- PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
106
- );
107
- });
108
- }
117
+ if (!localName?.trim()) {
118
+ return false;
119
+ }
109
120
 
110
- function isOneKeyPeripheral(peripheral: Peripheral) {
111
- const deviceName = peripheral.advertisement?.localName || null;
112
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
121
+ return (
122
+ hasOnekeyCommunicationService(serviceUuids) &&
123
+ isOnekeyBluetoothDevice({
124
+ id: peripheral.id,
125
+ localName,
126
+ serviceUuids,
127
+ })
128
+ );
113
129
  }
114
130
 
115
131
  /**
@@ -240,6 +256,11 @@ async function initializeNoble(): Promise<void> {
240
256
  noble = require('@stoprocent/noble') as NobleModule;
241
257
  logger?.info('[NobleBLE] Noble library loaded');
242
258
 
259
+ // Register the process-lifetime state listener before any early return:
260
+ // the poweredOn fast path below would otherwise skip it for the whole
261
+ // session, leaving poweredOff cache/state reconciliation dead.
262
+ setupPersistentStateListener();
263
+
243
264
  // Wait for Bluetooth to be ready
244
265
  await new Promise<void>((resolve, reject) => {
245
266
  if (!noble) {
@@ -252,9 +273,6 @@ async function initializeNoble(): Promise<void> {
252
273
  return;
253
274
  }
254
275
 
255
- // Setup persistent state listener before initialization
256
- setupPersistentStateListener();
257
-
258
276
  const timeout = setTimeout(() => {
259
277
  reject(
260
278
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
@@ -290,9 +308,12 @@ async function initializeNoble(): Promise<void> {
290
308
  });
291
309
 
292
310
  // Set up device discovery
293
- noble.on('discover', (peripheral: Peripheral) => {
294
- handleDeviceDiscovered(peripheral);
295
- });
311
+ if (!persistentDiscoverListener) {
312
+ persistentDiscoverListener = (peripheral: Peripheral) => {
313
+ handleDeviceDiscovered(peripheral);
314
+ };
315
+ noble.on('discover', persistentDiscoverListener);
316
+ }
296
317
 
297
318
  logger?.info('[NobleBLE] Noble initialized successfully');
298
319
  } catch (error) {
@@ -323,6 +344,76 @@ interface DeviceCleanupOptions {
323
344
  reason?: string;
324
345
  }
325
346
 
347
+ // One timer per device, in the main process so a reload cannot orphan a link.
348
+ const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
349
+
350
+ // A fired timer cannot be cancelled; connect awaits its disconnect or the fast
351
+ // path hands back a link torn down a moment later (BleTimeoutError 713).
352
+ const idleDisconnectInFlight = new Map<string, Promise<void>>();
353
+
354
+ function clearIdleDisconnect(deviceId: string): void {
355
+ const timer = idleDisconnectTimers.get(deviceId);
356
+ if (timer) {
357
+ clearTimeout(timer);
358
+ idleDisconnectTimers.delete(deviceId);
359
+ }
360
+ }
361
+
362
+ /** Settle any keep-alive disconnect already running for this device. */
363
+ async function awaitIdleDisconnect(deviceId: string): Promise<void> {
364
+ const pending = idleDisconnectInFlight.get(deviceId);
365
+ if (pending) await pending.catch(() => undefined);
366
+ }
367
+
368
+ function broadcastToAllWebContents(channel: string, payload: unknown): void {
369
+ try {
370
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
371
+ const { webContents: electronWebContents } = require('electron') as {
372
+ webContents: { getAllWebContents(): WebContents[] };
373
+ };
374
+ for (const wc of electronWebContents.getAllWebContents()) {
375
+ if (!wc.isDestroyed()) wc.send(channel, payload);
376
+ }
377
+ } catch (error) {
378
+ logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
379
+ }
380
+ }
381
+
382
+ function armIdleDisconnect(
383
+ deviceId: string,
384
+ ms: number = BLE_IDLE_DISCONNECT_MS,
385
+ reason: 'idle' | 'busy-backstop' = 'idle'
386
+ ): void {
387
+ clearIdleDisconnect(deviceId);
388
+ idleDisconnectTimers.set(
389
+ deviceId,
390
+ setTimeout(() => {
391
+ idleDisconnectTimers.delete(deviceId);
392
+ if (!connectedDevices.has(deviceId)) return;
393
+ logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
394
+ const peripheral = connectedDevices.get(deviceId);
395
+ const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
396
+ const pending = disconnectDevice(deviceId)
397
+ .then(() => {
398
+ // A call is still in flight here; it must reject, not hang.
399
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
400
+ id: deviceId,
401
+ name: deviceName,
402
+ });
403
+ })
404
+ .catch(error => {
405
+ logger?.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
406
+ })
407
+ .finally(() => {
408
+ if (idleDisconnectInFlight.get(deviceId) === pending) {
409
+ idleDisconnectInFlight.delete(deviceId);
410
+ }
411
+ });
412
+ idleDisconnectInFlight.set(deviceId, pending);
413
+ }, ms)
414
+ );
415
+ }
416
+
326
417
  /**
327
418
  * Unified device cleanup function - single entry point for all cleanup operations
328
419
  */
@@ -348,12 +439,24 @@ function cleanupDevice(
348
439
  cancelOperations,
349
440
  });
350
441
 
442
+ clearIdleDisconnect(deviceId);
443
+
351
444
  // Get device info before cleanup
352
445
  const peripheral = connectedDevices.get(deviceId);
353
446
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
354
447
 
355
448
  // 1. Clean up connection state
356
449
  if (cleanupConnection) {
450
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
451
+ if (disconnectEntry) {
452
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
453
+ deviceDisconnectListeners.delete(deviceId);
454
+ }
455
+ const mtuEntry = deviceMtuListeners.get(deviceId);
456
+ if (mtuEntry) {
457
+ mtuEntry.peripheral.removeListener('mtu', mtuEntry.listener);
458
+ deviceMtuListeners.delete(deviceId);
459
+ }
357
460
  connectedDevices.delete(deviceId);
358
461
  deviceCharacteristics.delete(deviceId);
359
462
  notificationCallbacks.delete(deviceId);
@@ -367,9 +470,11 @@ function cleanupDevice(
367
470
  discoveredDevices.delete(deviceId);
368
471
  }
369
472
 
370
- // 3. Send disconnect event (if needed)
371
- if (sendDisconnectEvent && webContents) {
372
- webContents.send(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
473
+ // 3. Send disconnect event (if needed). Broadcast, not the captured
474
+ // webContents: a kept-alive link outlives a renderer soft restart, and the
475
+ // new renderer still needs to hear the device dropped (same as the idle path).
476
+ if (sendDisconnectEvent) {
477
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
373
478
  id: deviceId,
374
479
  name: deviceName,
375
480
  });
@@ -391,6 +496,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
391
496
 
392
497
  cleanupDevice(deviceId, webContents, {
393
498
  cleanupConnection: true,
499
+ // Same stale-object hazard as manual disconnect: an externally dropped
500
+ // link invalidates the cached peripheral for the next reconnect.
501
+ cleanupDiscoveredCache: true,
394
502
  sendDisconnectEvent: true,
395
503
  cancelOperations: true,
396
504
  reason: 'auto-disconnect',
@@ -403,18 +511,45 @@ function setupDisconnectListener(
403
511
  deviceId: string,
404
512
  webContents: WebContents
405
513
  ): void {
406
- // Remove any existing disconnect listeners to avoid duplicates
407
- peripheral.removeAllListeners('disconnect');
514
+ const existing = deviceDisconnectListeners.get(deviceId);
515
+ if (existing) {
516
+ existing.peripheral.removeListener('disconnect', existing.listener);
517
+ }
408
518
 
409
- // Set up new disconnect listener
410
- peripheral.on('disconnect', () => {
519
+ const listener = () => {
411
520
  handleDeviceDisconnect(deviceId, webContents);
412
- });
521
+ };
522
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
523
+ peripheral.on('disconnect', listener);
524
+ setupMtuListener(peripheral, deviceId, webContents);
525
+ }
526
+
527
+ function setupMtuListener(
528
+ peripheral: Peripheral,
529
+ deviceId: string,
530
+ webContents: WebContents
531
+ ): void {
532
+ const existing = deviceMtuListeners.get(deviceId);
533
+ if (existing) {
534
+ existing.peripheral.removeListener('mtu', existing.listener);
535
+ }
536
+
537
+ const listener = (mtu: number) => {
538
+ if (!Number.isFinite(mtu) || mtu <= 0) return;
539
+ // A kept-alive link can outlive the renderer this closure captured.
540
+ if (webContents.isDestroyed()) return;
541
+ webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
542
+ id: deviceId,
543
+ mtu,
544
+ });
545
+ };
546
+ deviceMtuListeners.set(deviceId, { peripheral, listener });
547
+ peripheral.on('mtu', listener);
413
548
  }
414
549
 
415
550
  // ===== Write helpers (inline) =====
416
551
 
417
- async function writeCharacteristicWithAck(
552
+ async function writeCharacteristicWithoutResponse(
418
553
  deviceId: string,
419
554
  writeCharacteristic: Characteristic,
420
555
  buffer: Buffer
@@ -462,7 +597,7 @@ async function attemptWindowsWriteUntilPaired(
462
597
  }
463
598
 
464
599
  try {
465
- await writeCharacteristicWithAck(deviceId, latestWrite, payload);
600
+ await writeCharacteristicWithoutResponse(deviceId, latestWrite, payload);
466
601
  } catch (e) {
467
602
  const errorMessage = e instanceof Error ? e.message : String(e);
468
603
  logger?.error('[BLE-Write] Windows write error', {
@@ -534,7 +669,11 @@ async function attemptWindowsWriteUntilPaired(
534
669
  );
535
670
  }
536
671
 
537
- async function transmitHexDataToDevice(deviceId: string, hexData: string): Promise<void> {
672
+ async function transmitHexDataToDevice(
673
+ deviceId: string,
674
+ hexData: string,
675
+ options?: NobleBleWriteOptions
676
+ ): Promise<void> {
538
677
  const characteristics = deviceCharacteristics.get(deviceId);
539
678
  const peripheral = connectedDevices.get(deviceId);
540
679
  if (!peripheral || !characteristics) {
@@ -543,9 +682,17 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
543
682
  `Device ${deviceId} not connected or characteristics not available`
544
683
  );
545
684
  }
685
+ // Request outstanding: swap the idle clock for the busy backstop.
686
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
546
687
 
547
688
  const toBuffer = Buffer.from(hexData, 'hex');
548
689
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
690
+ const packetCapacity = resolveBlePacketCapacity(
691
+ peripheral.mtu,
692
+ BLE_PACKET_SIZE_MAXIMUM,
693
+ BLE_PACKET_SIZE_FALLBACK
694
+ );
695
+ const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
549
696
 
550
697
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
551
698
  // macOS / Linux or already paired on Windows: direct write
@@ -556,14 +703,14 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
556
703
  `Write characteristic not available for ${deviceId}`
557
704
  );
558
705
  }
559
- if (toBuffer.length <= BLE_PACKET_SIZE) {
560
- await wait(UNIFIED_WRITE_DELAY);
561
- await writeCharacteristicWithAck(deviceId, writeCharacteristic, toBuffer);
706
+ if (toBuffer.length <= packetCapacity) {
707
+ if (pacingDelayMs > 0) await wait(pacingDelayMs);
708
+ await writeCharacteristicWithoutResponse(deviceId, writeCharacteristic, toBuffer);
562
709
  return;
563
710
  }
564
711
  // chunked
565
712
  for (let offset = 0; offset < toBuffer.length; ) {
566
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
713
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
567
714
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
568
715
  offset += chunkSize;
569
716
  const latest = doGetWriteCharacteristic();
@@ -573,23 +720,23 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
573
720
  `Write characteristic not available for ${deviceId}`
574
721
  );
575
722
  }
576
- await writeCharacteristicWithAck(deviceId, latest, chunk);
577
- if (offset < toBuffer.length) {
578
- await wait(UNIFIED_WRITE_DELAY);
723
+ await writeCharacteristicWithoutResponse(deviceId, latest, chunk);
724
+ if (offset < toBuffer.length && pacingDelayMs > 0) {
725
+ await wait(pacingDelayMs);
579
726
  }
580
727
  }
581
728
  return;
582
729
  }
583
730
 
584
731
  // Windows unpaired path: use loop
585
- if (toBuffer.length <= BLE_PACKET_SIZE) {
586
- await wait(UNIFIED_WRITE_DELAY);
732
+ if (toBuffer.length <= packetCapacity) {
733
+ if (pacingDelayMs > 0) await wait(pacingDelayMs);
587
734
  await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
588
735
  return;
589
736
  }
590
737
  // chunked loop
591
738
  for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
592
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
739
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
593
740
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
594
741
  offset += chunkSize;
595
742
  await attemptWindowsWriteUntilPaired(
@@ -598,8 +745,8 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
598
745
  chunk,
599
746
  `chunk-${idx + 1}`
600
747
  );
601
- if (offset < toBuffer.length) {
602
- await wait(UNIFIED_WRITE_DELAY);
748
+ if (offset < toBuffer.length && pacingDelayMs > 0) {
749
+ await wait(pacingDelayMs);
603
750
  }
604
751
  }
605
752
  }
@@ -628,19 +775,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
628
775
  function ensureDiscoverListener(): void {
629
776
  if (!noble) return;
630
777
 
631
- // Check if discover listener exists by checking listener count
632
- const listenerCount = (noble as any).listenerCount('discover');
633
-
634
- if (listenerCount === 0) {
778
+ if (!persistentDiscoverListener) {
635
779
  logger?.info('[NobleBLE] Discover listener missing, re-adding it');
636
- noble.on('discover', (peripheral: Peripheral) => {
780
+ persistentDiscoverListener = (peripheral: Peripheral) => {
637
781
  handleDeviceDiscovered(peripheral);
638
- });
782
+ };
783
+ noble.on('discover', persistentDiscoverListener);
639
784
  } else {
640
- logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
785
+ logger?.debug('[NobleBLE] Discover listener already registered');
641
786
  }
642
787
  }
643
788
 
789
+ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
790
+ await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
791
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
792
+ timeoutBehavior: 'resolve',
793
+ });
794
+ }
795
+
644
796
  // Perform targeted scan for a specific device ID
645
797
  // Uses self-contained local listener pattern - no global state needed
646
798
  async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
@@ -654,39 +806,50 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
654
806
  logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
655
807
 
656
808
  return new Promise((resolve, reject) => {
809
+ let settled = false;
810
+
811
+ const finish = async (peripheral: Peripheral | null, error?: Error) => {
812
+ if (settled) return;
813
+ settled = true;
814
+ if (timeoutId) clearTimeout(timeoutId);
815
+ nobleInstance.removeListener('discover', onDiscover);
816
+ await waitForNobleScanStop(nobleInstance);
817
+
818
+ if (error) {
819
+ logger?.error('[NobleBLE] Failed to start targeted scan:', error);
820
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
821
+ return;
822
+ }
823
+ if (peripheral) {
824
+ discoveredDevices.set(peripheral.id, peripheral);
825
+ }
826
+ resolve(peripheral);
827
+ };
828
+
657
829
  // Local discover listener - only matches target device
658
830
  const onDiscover = (peripheral: Peripheral) => {
659
- if (peripheral.id === targetDeviceId) {
831
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
660
832
  logger?.info('[NobleBLE] Target device found during targeted scan:', {
661
833
  id: peripheral.id,
662
834
  name: peripheral.advertisement?.localName,
663
835
  });
664
- clearTimeout(timeoutId);
665
- nobleInstance.removeListener('discover', onDiscover);
666
- nobleInstance.stopScanning();
667
- discoveredDevices.set(peripheral.id, peripheral);
668
- resolve(peripheral);
836
+ finish(peripheral).catch(reject);
669
837
  }
670
838
  };
671
839
 
672
- // Timeout handler - must be after onDiscover so it can reference it
673
840
  const timeoutId = setTimeout(() => {
674
- nobleInstance.removeListener('discover', onDiscover);
675
- nobleInstance.stopScanning();
676
841
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
677
- resolve(null);
678
- }, FAST_SCAN_TIMEOUT);
842
+ finish(null).catch(reject);
843
+ }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
679
844
 
680
845
  // Add local listener for this scan
681
846
  nobleInstance.on('discover', onDiscover);
682
847
 
683
- // Start scanning — no service UUID filter (Pro2 may use different service UUID)
684
- nobleInstance.startScanning([], false, (error?: Error) => {
848
+ // Allow repeated advertisements so a service-only packet can be followed by
849
+ // the named scan response needed to validate the target peripheral.
850
+ nobleInstance.startScanning([], true, (error?: Error) => {
685
851
  if (error) {
686
- clearTimeout(timeoutId);
687
- nobleInstance.removeListener('discover', onDiscover);
688
- logger?.error('[NobleBLE] Failed to start targeted scan:', error);
689
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
852
+ finish(null, error).catch(reject);
690
853
  return;
691
854
  }
692
855
  logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
@@ -720,44 +883,47 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
720
883
  const devices: DeviceInfo[] = [];
721
884
  let intervalId: ReturnType<typeof setInterval> | undefined;
722
885
 
723
- // Cleanup function: clears both timeout and interval
724
- const cleanup = () => {
886
+ // Cleanup function: clears timers and waits until Noble confirms scanning
887
+ // has stopped. Resolving enumerate before this callback creates a race with
888
+ // an immediately-following connection attempt.
889
+ const cleanup = async () => {
725
890
  clearTimeout(timeoutId);
726
891
  if (intervalId) clearInterval(intervalId);
727
- nobleInstance.stopScanning();
892
+ await waitForNobleScanStop(nobleInstance);
728
893
  };
729
894
 
730
895
  // Collect discovered devices into the devices array
731
- const checkDevices = () => {
732
- discoveredDevices.forEach((peripheral, id) => {
733
- const existingDevice = devices.find(d => d.id === id);
734
- if (!existingDevice) {
735
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
736
- devices.push({
737
- commType: 'electron-ble',
738
- id,
739
- name: deviceName,
740
- state: peripheral.state || 'disconnected',
741
- });
742
- }
896
+ const pushDevice = (peripheral: Peripheral, id: string) => {
897
+ if (devices.some(d => d.id === id)) return;
898
+ devices.push({
899
+ commType: 'electron-ble',
900
+ id,
901
+ name: peripheral.advertisement?.localName || 'Unknown Device',
902
+ state: peripheral.state || 'disconnected',
743
903
  });
744
904
  };
905
+ const checkDevices = () => {
906
+ discoveredDevices.forEach(pushDevice);
907
+ // A linked device stops advertising, so the scan never rediscovers it.
908
+ connectedDevices.forEach(pushDevice);
909
+ };
745
910
 
746
911
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
747
- const timeoutId = setTimeout(() => {
912
+ const timeoutId = setTimeout(async () => {
748
913
  // Final collection before resolving — catches devices discovered near the deadline
749
914
  checkDevices();
750
- cleanup();
915
+ await cleanup();
751
916
  logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
752
917
  resolve(devices);
753
918
  }, DEVICE_SCAN_TIMEOUT);
754
919
 
755
920
  // Start scanning without a service UUID filter so Pro2 advertisements with
756
- // short vendor UUIDs can be found, but only OneKey candidates are logged/returned.
921
+ // short vendor UUIDs can be found. Repeated advertisements are required when
922
+ // the local name arrives in a later scan response; discoveredDevices handles deduplication.
757
923
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
758
- nobleInstance.startScanning([], false, (error?: Error) => {
924
+ nobleInstance.startScanning([], true, async (error?: Error) => {
759
925
  if (error) {
760
- cleanup();
926
+ await cleanup();
761
927
  logger?.error('[NobleBLE] Failed to start scanning:', error);
762
928
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
763
929
  return;
@@ -774,30 +940,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
774
940
  // Stop scanning
775
941
  async function stopScanning(): Promise<void> {
776
942
  if (!noble) return;
777
-
778
- return new Promise<void>(resolve => {
779
- if (!noble) {
780
- resolve();
781
- return;
782
- }
783
-
784
- noble.stopScanning(() => {
785
- logger?.info('[NobleBLE] Scanning stopped');
786
- resolve();
787
- });
788
- });
943
+ const nobleInstance = noble;
944
+ await waitForNobleScanStop(nobleInstance);
945
+ logger?.info('[NobleBLE] Scanning stopped');
789
946
  }
790
947
 
791
948
  // Clean up all Noble listeners (for app exit)
792
949
  function cleanupNobleListeners(): void {
793
950
  if (!noble) return;
794
951
 
795
- // Remove all listeners to prevent memory leaks
796
- // Noble uses EventEmitter, need to use removeAllListeners
797
952
  try {
798
- (noble as any).removeAllListeners('discover');
799
- (noble as any).removeAllListeners('stateChange');
800
- logger?.info('[NobleBLE] All Noble listeners cleaned up');
953
+ if (persistentDiscoverListener) {
954
+ noble.removeListener('discover', persistentDiscoverListener);
955
+ persistentDiscoverListener = null;
956
+ }
957
+ logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
801
958
  } catch (error) {
802
959
  logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
803
960
  }
@@ -814,6 +971,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
814
971
  id: peripheral.id,
815
972
  name: deviceName,
816
973
  state: peripheral.state || 'disconnected',
974
+ ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
817
975
  };
818
976
  }
819
977
 
@@ -826,6 +984,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
826
984
  id: connectedPeripheral.id,
827
985
  name: deviceName,
828
986
  state: connectedPeripheral.state || 'connected',
987
+ ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
829
988
  };
830
989
  }
831
990
 
@@ -905,14 +1064,7 @@ async function discoverServicesAndCharacteristics(
905
1064
  });
906
1065
 
907
1066
  // Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
908
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
909
- if (!service) {
910
- logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
911
- service =
912
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
913
- services.find(s => !isGenericBleService(s.uuid)) ||
914
- services[0];
915
- }
1067
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
916
1068
  if (!service) {
917
1069
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
918
1070
  }
@@ -938,12 +1090,9 @@ async function discoverServicesAndCharacteristics(
938
1090
  let notifyCharacteristic: Characteristic | null = null;
939
1091
 
940
1092
  for (const characteristic of characteristics) {
941
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
942
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
943
-
944
- if (uuidKey === NORMALIZED_WRITE_UUID) {
1093
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
945
1094
  writeCharacteristic = characteristic;
946
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
1095
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
947
1096
  notifyCharacteristic = characteristic;
948
1097
  }
949
1098
  }
@@ -1000,29 +1149,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1000
1149
 
1001
1150
  // Step 2: Force disconnect if connected
1002
1151
  if (peripheral.state === 'connected') {
1003
- // Remove disconnect listener before intentional disconnect to avoid spurious events
1004
- peripheral.removeAllListeners('disconnect');
1005
- await new Promise<void>(resolve => {
1006
- peripheral.disconnect(() => {
1007
- logger?.info('[NobleBLE] Force disconnect completed');
1008
- resolve();
1009
- });
1010
- });
1152
+ await runBleCallbackOperation(
1153
+ callback =>
1154
+ peripheral.disconnect(() => {
1155
+ logger?.info('[NobleBLE] Force disconnect completed');
1156
+ callback();
1157
+ }),
1158
+ { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
1159
+ );
1011
1160
  }
1012
1161
 
1013
1162
  // Step 3: Re-establish connection
1014
- await new Promise<void>((resolve, reject) => {
1015
- peripheral.connect((error: Error | undefined) => {
1016
- if (error) {
1017
- logger?.error('[NobleBLE] Force reconnect failed:', error);
1018
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1019
- } else {
1020
- logger?.info('[NobleBLE] Force reconnect successful');
1021
- connectedDevices.set(deviceId, peripheral);
1022
- resolve();
1023
- }
1024
- });
1163
+ await runBleCallbackOperation(callback => peripheral.connect(callback), {
1164
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1165
+ timeoutBehavior: 'reject',
1025
1166
  });
1167
+ logger?.info('[NobleBLE] Force reconnect successful');
1168
+ connectedDevices.set(deviceId, peripheral);
1026
1169
 
1027
1170
  // Wait for connection to stabilize
1028
1171
  await wait(500);
@@ -1153,20 +1296,100 @@ async function setupConnectionAndDiscoverServices(
1153
1296
  deviceId: string,
1154
1297
  webContents: WebContents
1155
1298
  ): Promise<CharacteristicPair> {
1156
- // Force reconnect to clear GATT cache
1157
- await forceReconnectPeripheral(peripheral, deviceId);
1158
1299
  setupDisconnectListener(peripheral, deviceId, webContents);
1159
1300
 
1160
- // Discover services and characteristics with retry
1161
1301
  try {
1162
1302
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1163
- } catch (error) {
1164
- // Last resort: fresh scan to get new peripheral object
1165
- logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1303
+ } catch (initialError) {
1304
+ logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1305
+ deviceId,
1306
+ error: initialError,
1307
+ });
1308
+ }
1309
+
1310
+ await forceReconnectPeripheral(peripheral, deviceId);
1311
+ setupDisconnectListener(peripheral, deviceId, webContents);
1312
+ try {
1313
+ return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1314
+ } catch (reconnectError) {
1315
+ logger?.error(
1316
+ '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1317
+ reconnectError
1318
+ );
1166
1319
  return freshScanAndDiscover(deviceId, webContents);
1167
1320
  }
1168
1321
  }
1169
1322
 
1323
+ // noble/mac never resolves for an id CoreBluetooth cannot retrieve.
1324
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1325
+ // Floor after a timeout: do not pay the probe on every attempt.
1326
+ const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
1327
+ const directConnectCooldownUntil = new Map<string, number>();
1328
+
1329
+ /**
1330
+ * Connect straight by id, skipping the ~650ms targeted scan. Both native
1331
+ * backends support it and emit a `discover` as a side effect: macOS resolves
1332
+ * via `retrievePeripheralsWithIdentifiers`, Windows synthesizes one for an
1333
+ * unknown address. Returns undefined when unavailable so the caller scans.
1334
+ *
1335
+ * Ported from the Trezor connector, where it is field-proven.
1336
+ */
1337
+ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1338
+ const nobleInstance = noble as
1339
+ | (typeof noble & {
1340
+ connectAsync?: (id: string) => Promise<Peripheral | undefined>;
1341
+ })
1342
+ | null;
1343
+ if (!nobleInstance?.connectAsync) return undefined;
1344
+
1345
+ const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1346
+ if (Date.now() < cooldownUntil) return undefined;
1347
+
1348
+ try {
1349
+ // The late-orphan guard must attach to THIS pending connect.
1350
+ const directPromise = nobleInstance.connectAsync(deviceId);
1351
+ const raced = await Promise.race([
1352
+ directPromise,
1353
+ new Promise<'timeout'>(resolve => {
1354
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1355
+ }),
1356
+ ]);
1357
+ if (raced === 'timeout') {
1358
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1359
+ logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1360
+ deviceId,
1361
+ });
1362
+ // Promise.race times out the caller only; a late success orphans the link.
1363
+ directPromise
1364
+ .then(late => {
1365
+ const latePeripheral = late ?? discoveredDevices.get(deviceId);
1366
+ if (
1367
+ latePeripheral &&
1368
+ latePeripheral.state === 'connected' &&
1369
+ !connectedDevices.has(deviceId)
1370
+ ) {
1371
+ latePeripheral.removeAllListeners('disconnect');
1372
+ latePeripheral.disconnect(() => undefined);
1373
+ }
1374
+ })
1375
+ .catch(() => undefined);
1376
+ return undefined;
1377
+ }
1378
+ // Backends emit `discover` as a side effect, so the cache may hold it.
1379
+ const peripheral = raced ?? discoveredDevices.get(deviceId);
1380
+ if (!peripheral || peripheral.state !== 'connected') return undefined;
1381
+ logger?.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
1382
+ return peripheral;
1383
+ } catch (error) {
1384
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1385
+ logger?.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
1386
+ deviceId,
1387
+ error: String(error),
1388
+ });
1389
+ return undefined;
1390
+ }
1391
+ }
1392
+
1170
1393
  // Connect to device - supports both discovered and direct connection modes
1171
1394
  async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1172
1395
  logger?.info('[NobleBLE] Connect device request:', {
@@ -1178,7 +1401,8 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1178
1401
  totalConnected: connectedDevices.size,
1179
1402
  });
1180
1403
 
1181
- let peripheral = discoveredDevices.get(deviceId);
1404
+ // enumerate clears the discovery map; a kept-alive link outlives it.
1405
+ let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1182
1406
 
1183
1407
  // If device not discovered, try a targeted scan for this specific device
1184
1408
  if (!peripheral) {
@@ -1193,6 +1417,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1193
1417
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1194
1418
  }
1195
1419
 
1420
+ // Reaches a bonded device that is not advertising; else falls to the scan.
1421
+ peripheral = await tryDirectConnectById(deviceId);
1422
+ if (peripheral) {
1423
+ discoveredDevices.set(deviceId, peripheral);
1424
+ }
1425
+ }
1426
+
1427
+ if (!peripheral) {
1196
1428
  // Perform a targeted scan to find the specific device
1197
1429
  try {
1198
1430
  const foundPeripheral = await performTargetedScan(deviceId);
@@ -1223,9 +1455,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1223
1455
  // If already connected but not in our connected devices map, add it
1224
1456
  if (!connectedDevices.has(deviceId)) {
1225
1457
  connectedDevices.set(deviceId, peripheral);
1226
- // Set up unified disconnect listener
1227
- setupDisconnectListener(peripheral, deviceId, webContents);
1228
1458
  }
1459
+ // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1460
+ // and MTU listeners may still hold the webContents of a soft-restarted
1461
+ // renderer, and the reuse fast path below returns before any other setup.
1462
+ setupDisconnectListener(peripheral, deviceId, webContents);
1229
1463
 
1230
1464
  // Check if we already have characteristics for this device
1231
1465
  if (deviceCharacteristics.has(deviceId)) {
@@ -1269,26 +1503,48 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1269
1503
  }
1270
1504
 
1271
1505
  // Setup connection and discover services
1272
- const characteristics = await setupConnectionAndDiscoverServices(
1273
- peripheral,
1274
- deviceId,
1275
- webContents
1276
- );
1277
- deviceCharacteristics.set(deviceId, characteristics);
1278
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1506
+ try {
1507
+ const characteristics = await setupConnectionAndDiscoverServices(
1508
+ peripheral,
1509
+ deviceId,
1510
+ webContents
1511
+ );
1512
+ deviceCharacteristics.set(deviceId, characteristics);
1513
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1514
+ } catch (setupError) {
1515
+ // The caller re-arms the timer only on success, so a left-up link strands.
1516
+ logger?.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
1517
+ await disconnectDevice(deviceId).catch(() => undefined);
1518
+ throw setupError;
1519
+ }
1279
1520
  return;
1280
1521
  }
1281
1522
 
1282
1523
  return new Promise((resolve, reject) => {
1524
+ let connectionTimedOut = false;
1283
1525
  const timeout = setTimeout(() => {
1526
+ connectionTimedOut = true;
1284
1527
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1285
- }, CONNECTION_TIMEOUT);
1528
+ }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1286
1529
 
1287
1530
  // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1288
1531
  const connectedPeripheral = peripheral as Peripheral;
1289
1532
  connectedPeripheral.connect(async (error: Error | undefined) => {
1290
1533
  clearTimeout(timeout);
1291
1534
 
1535
+ // Noble may invoke the callback after the SDK timed out and released the request.
1536
+ // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1537
+ if (connectionTimedOut) {
1538
+ if (!error) {
1539
+ try {
1540
+ connectedPeripheral.disconnect(() => undefined);
1541
+ } catch {
1542
+ // Best-effort cleanup only.
1543
+ }
1544
+ }
1545
+ return;
1546
+ }
1547
+
1292
1548
  if (error) {
1293
1549
  logger?.error('[NobleBLE] Connection failed:', error);
1294
1550
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
@@ -1325,20 +1581,25 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1325
1581
  return;
1326
1582
  }
1327
1583
 
1328
- return new Promise<void>(resolve => {
1329
- // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1330
- peripheral.removeAllListeners('disconnect');
1331
-
1332
- peripheral.disconnect(() => {
1333
- // Clean up device state using unified function
1334
- cleanupDevice(deviceId, undefined, {
1335
- cleanupConnection: true,
1336
- sendDisconnectEvent: false,
1337
- cancelOperations: true,
1338
- reason: 'manual-disconnect',
1339
- });
1340
- resolve();
1341
- });
1584
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1585
+ if (disconnectEntry) {
1586
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
1587
+ deviceDisconnectListeners.delete(deviceId);
1588
+ }
1589
+
1590
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1591
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1592
+ timeoutBehavior: 'resolve',
1593
+ });
1594
+ cleanupDevice(deviceId, undefined, {
1595
+ cleanupConnection: true,
1596
+ // macOS returns zero GATT services when a previously-disconnected
1597
+ // peripheral object is reconnected; drop the discovery cache so the next
1598
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1599
+ cleanupDiscoveredCache: true,
1600
+ sendDisconnectEvent: false,
1601
+ cancelOperations: true,
1602
+ reason: 'manual-disconnect',
1342
1603
  });
1343
1604
  }
1344
1605
 
@@ -1359,17 +1620,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1359
1620
  subscriptionOperations.set(deviceId, 'unsubscribing');
1360
1621
 
1361
1622
  try {
1362
- await new Promise<void>((resolve, reject) => {
1363
- notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1364
- if (error) {
1365
- logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1366
- reject(error);
1367
- } else {
1368
- logger?.info('[NobleBLE] Notification unsubscription successful');
1369
- resolve();
1370
- }
1371
- });
1623
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1624
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1625
+ timeoutBehavior: 'resolve',
1372
1626
  });
1627
+ logger?.info('[NobleBLE] Notification unsubscription completed');
1373
1628
 
1374
1629
  // Remove all listeners and clear subscription status
1375
1630
  notifyCharacteristic.removeAllListeners('data');
@@ -1462,19 +1717,13 @@ async function subscribeNotifications(
1462
1717
  notifyCharacteristic: Characteristic
1463
1718
  ): Promise<void> {
1464
1719
  // Listeners already cleaned up above, no need to repeat
1465
- await new Promise<void>(resolve => {
1466
- notifyCharacteristic.unsubscribe(() => {
1467
- resolve();
1468
- });
1720
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1721
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1722
+ timeoutBehavior: 'resolve',
1469
1723
  });
1470
- await new Promise<void>((resolve, reject) => {
1471
- notifyCharacteristic.subscribe((error?: Error) => {
1472
- if (error) {
1473
- reject(error);
1474
- return;
1475
- }
1476
- resolve();
1477
- });
1724
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1725
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1726
+ timeoutBehavior: 'reject',
1478
1727
  });
1479
1728
 
1480
1729
  notifyCharacteristic.on('data', (data: Buffer) => {
@@ -1506,12 +1755,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1506
1755
 
1507
1756
  // @ts-ignore – electron is only available at runtime
1508
1757
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1509
- const { ipcMain } = require('electron');
1758
+ const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1759
+
1760
+ // Electron throws on duplicate channels and setup re-runs on soft restart.
1761
+ const handle: IpcMain['handle'] = (channel, listener) => {
1762
+ ipcMain.removeHandler(channel);
1763
+ ipcMain.handle(channel, listener);
1764
+ };
1510
1765
 
1511
1766
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1512
1767
 
1513
1768
  // Handle enumerate request
1514
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1769
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1515
1770
  try {
1516
1771
  const devices = await enumerateDevices();
1517
1772
  safeLog(logger, 'debug', 'Enumeration completed', {
@@ -1526,18 +1781,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1526
1781
  });
1527
1782
 
1528
1783
  // Handle stop scan request
1529
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1784
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1530
1785
  await stopScanning();
1531
1786
  });
1532
1787
 
1533
1788
  // Handle get device request
1534
- ipcMain.handle(
1789
+ handle(
1535
1790
  EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
1536
1791
  (_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
1537
1792
  );
1538
1793
 
1539
1794
  // Handle connect request
1540
- ipcMain.handle(
1795
+ handle(
1541
1796
  EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT,
1542
1797
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1543
1798
  logger?.info('[NobleBLE] IPC CONNECT request received:', {
@@ -1546,12 +1801,37 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1546
1801
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1547
1802
  totalConnectedDevices: connectedDevices.size,
1548
1803
  });
1549
- await connectDevice(deviceId, webContents);
1804
+ // Must not fire mid-connect (pairing can take ~30s).
1805
+ clearIdleDisconnect(deviceId);
1806
+ // A fired timer must settle first, or the fast path returns a dying link.
1807
+ await awaitIdleDisconnect(deviceId);
1808
+ try {
1809
+ await connectDevice(deviceId, webContents);
1810
+ } finally {
1811
+ // This timer is the only thing that frees a kept-alive link.
1812
+ if (connectedDevices.has(deviceId)) {
1813
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1814
+ }
1815
+ }
1816
+ }
1817
+ );
1818
+
1819
+ // Logical release: start the idle countdown, keep the link for the next call.
1820
+ handle(
1821
+ EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
1822
+ (_event: IpcMainInvokeEvent, deviceId: string, keepSession?: boolean) => {
1823
+ if (!connectedDevices.has(deviceId)) return;
1824
+ // Mid-flow caller will be back; the short window would cut an update.
1825
+ if (keepSession) {
1826
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1827
+ return;
1828
+ }
1829
+ armIdleDisconnect(deviceId);
1550
1830
  }
1551
1831
  );
1552
1832
 
1553
1833
  // Handle disconnect request
1554
- ipcMain.handle(
1834
+ handle(
1555
1835
  EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
1556
1836
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1557
1837
  await disconnectDevice(deviceId);
@@ -1559,34 +1839,42 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1559
1839
  );
1560
1840
 
1561
1841
  // Handle write request
1562
- ipcMain.handle(
1842
+ handle(
1563
1843
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
1564
- async (_event: IpcMainInvokeEvent, deviceId: string, hexData: string) => {
1565
- await transmitHexDataToDevice(deviceId, hexData);
1844
+ async (
1845
+ _event: IpcMainInvokeEvent,
1846
+ deviceId: string,
1847
+ hexData: string,
1848
+ options?: NobleBleWriteOptions
1849
+ ) => {
1850
+ await transmitHexDataToDevice(deviceId, hexData, options);
1566
1851
  }
1567
1852
  );
1568
1853
 
1569
1854
  // Handle subscribe request
1570
- ipcMain.handle(
1855
+ handle(
1571
1856
  EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
1572
1857
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1573
1858
  await subscribeNotifications(deviceId, (data: string) => {
1574
1859
  // Send data back to renderer process
1575
1860
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1576
1861
  });
1862
+ // Still acquiring (in flight) — busy backstop, not the idle clock.
1863
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1577
1864
  }
1578
1865
  );
1579
1866
 
1580
1867
  // Handle unsubscribe request
1581
- ipcMain.handle(
1868
+ handle(
1582
1869
  EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
1583
1870
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1584
1871
  await unsubscribeNotifications(deviceId);
1872
+ armIdleDisconnect(deviceId);
1585
1873
  }
1586
1874
  );
1587
1875
 
1588
1876
  // Handle cancel pairing: cleanup all connected devices
1589
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1877
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1590
1878
  const deviceIds = Array.from(connectedDevices.keys());
1591
1879
  logger?.info('[NobleBLE] Cancel pairing invoked', {
1592
1880
  platform: process.platform,
@@ -1607,7 +1895,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1607
1895
  });
1608
1896
 
1609
1897
  // Handle Bluetooth availability check request
1610
- ipcMain.handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1898
+ handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1611
1899
  try {
1612
1900
  const bluetoothStatus = await checkBluetoothAvailability();
1613
1901
  safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
@@ -1626,32 +1914,26 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1626
1914
  // Cleanup on app quit
1627
1915
  webContents.on('destroyed', () => {
1628
1916
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1917
+ (async () => {
1918
+ const deviceIds = Array.from(connectedDevices.keys());
1919
+ for (const deviceId of deviceIds) {
1920
+ await unsubscribeNotifications(deviceId).catch(() => undefined);
1921
+ await disconnectDevice(deviceId).catch(() => undefined);
1922
+ }
1629
1923
 
1630
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1631
- const deviceIds = Array.from(connectedDevices.keys());
1632
- deviceIds.forEach(deviceId => {
1633
- cleanupDevice(deviceId, undefined, {
1634
- cleanupConnection: true,
1635
- sendDisconnectEvent: false,
1636
- cancelOperations: true,
1637
- reason: 'app-quit',
1638
- });
1924
+ await stopScanning().catch(() => undefined);
1925
+ // persistentStateListener is process-lifetime, NOT per-window: this
1926
+ // destroy handler also fires on a renderer soft restart, and removing
1927
+ // the listener here permanently killed poweredOff/poweredOn
1928
+ // reconciliation for the rest of the process (nothing re-registers it:
1929
+ // initializeNoble early-returns once noble is loaded). The null-guard
1930
+ // in setupPersistentStateListener keeps it deduped to one instance.
1931
+ cleanupNobleListeners();
1932
+ discoveredDevices.clear();
1933
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1934
+ })().catch(error => {
1935
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1639
1936
  });
1640
-
1641
- // 2. Stop scanning
1642
- stopScanning();
1643
-
1644
- // 3. Clean up Noble listeners
1645
- if (noble && persistentStateListener) {
1646
- noble.removeListener('stateChange', persistentStateListener);
1647
- persistentStateListener = null;
1648
- }
1649
- cleanupNobleListeners();
1650
-
1651
- // 4. Clear discovered devices cache
1652
- discoveredDevices.clear();
1653
-
1654
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1655
1937
  });
1656
1938
 
1657
1939
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');