1+ import Combine
12import CoreBluetooth
23import Foundation
34import Observation
@@ -47,6 +48,15 @@ class TrezorBLEManager: NSObject {
4748 private var connectedPeripheral : CBPeripheral ?
4849 private var connectedPath : String ?
4950
51+ /// Paths whose disconnect was user-initiated (via `disconnect(path:)`), so the
52+ /// spontaneous-disconnect signal is suppressed for deliberate teardowns.
53+ private var expectedDisconnectPaths : Set < String > = [ ]
54+
55+ /// Emits the device path when an established connection drops for a reason the
56+ /// user did not request — device out of range or phone Bluetooth turned off.
57+ /// `TrezorManager` subscribes (via `TrezorTransport`) to clear the stale session.
58+ let externalDisconnectPublisher = PassthroughSubject < String , Never > ( )
59+
5060 /// GATT characteristics
5161 private var writeCharacteristic : CBCharacteristic ?
5262 private var notifyCharacteristic : CBCharacteristic ?
@@ -284,6 +294,10 @@ class TrezorBLEManager: NSObject {
284294 // Clean up any stale connection state (preserves discoveredPeripherals cache)
285295 cleanupConnectionState ( )
286296
297+ // Drop any stale deliberate-disconnect marker from a prior session so it can't
298+ // suppress a future spontaneous drop on this reused path.
299+ expectedDisconnectPaths. remove ( path)
300+
287301 debugLog ( " connectOnce: \( path) " )
288302
289303 // Try to get peripheral from cache first
@@ -437,6 +451,10 @@ class TrezorBLEManager: NSObject {
437451
438452 debugLog ( " Disconnecting: \( path) " )
439453
454+ // Mark this as a deliberate teardown so the resulting didDisconnect callback
455+ // does not surface as a spontaneous (out-of-range) drop.
456+ expectedDisconnectPaths. insert ( path)
457+
440458 if let notifyChar = notifyCharacteristic {
441459 peripheral. setNotifyValue ( false , for: notifyChar)
442460 }
@@ -550,6 +568,20 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
550568 self . isScanning = false
551569 }
552570 }
571+
572+ // Bluetooth turned off / unauthorized invalidates any live connection. iOS does
573+ // not guarantee a per-peripheral didDisconnect on power-off, so treat it as a
574+ // spontaneous drop here: clear internal state and surface the disconnect.
575+ if central. state != . poweredOn, let path = connectedPath {
576+ connectedPeripheral = nil
577+ connectedPath = nil
578+ writeCharacteristic = nil
579+ notifyCharacteristic = nil
580+ readQueue. clear ( )
581+ if expectedDisconnectPaths. remove ( path) == nil {
582+ externalDisconnectPublisher. send ( path)
583+ }
584+ }
553585 }
554586
555587 func centralManager( _ central: CBCentralManager , didDiscover peripheral: CBPeripheral ,
@@ -592,7 +624,11 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
592624 takeConnectContinuation ( ) ? . resume ( throwing: error ?? TrezorBLEError . connectionFailed)
593625 }
594626
595- func centralManager( _ central: CBCentralManager , didDisconnectPeripheral peripheral: CBPeripheral , error: Error ? ) {
627+ func centralManager(
628+ _ central: CBCentralManager ,
629+ didDisconnectPeripheral peripheral: CBPeripheral ,
630+ error: Error ?
631+ ) {
596632 debugLog ( " didDisconnect: \( peripheral. identifier) \( error. map { " error: \( $0. localizedDescription) " } ?? " " ) " )
597633
598634 let disconnectError = error ?? TrezorBLEError . connectionFailed
@@ -603,12 +639,23 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
603639 takeNotificationContinuation ( ) ? . resume ( throwing: disconnectError)
604640 takeWriteContinuation ( ) ? . resume ( throwing: disconnectError)
605641
642+ let path = " ble: \( peripheral. identifier. uuidString) "
643+ // Consume any deliberate-disconnect marker even when disconnect(path:) already
644+ // cleared connection state, so it can't be mistaken for a future real drop.
645+ let wasExpected = expectedDisconnectPaths. remove ( path) != nil
646+
606647 if connectedPeripheral? . identifier == peripheral. identifier {
607648 connectedPeripheral = nil
608649 connectedPath = nil
609650 writeCharacteristic = nil
610651 notifyCharacteristic = nil
611652 readQueue. clear ( )
653+
654+ // Surface a spontaneous drop (out of range) so the session can be cleared.
655+ // A deliberate disconnect(path:) is suppressed via expectedDisconnectPaths.
656+ if !wasExpected {
657+ externalDisconnectPublisher. send ( path)
658+ }
612659 }
613660 }
614661}
0 commit comments