From 85e5314a93c15426f6fa00d8313572dd9100093a Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 3 Jun 2026 17:05:08 +0200 Subject: [PATCH] fix: reconnect a bonded peripheral instead of re-pairing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A peripheral that drops while held on this Mac (power cycle, briefly out of range, wake) stays bonded here — its link key is intact — so macOS reconnects it on its own. The auto-reconnect watcher, catching it reachable-but-not-yet-connected, ran IOBluetoothDevicePair.start() on it anyway. Re-pairing an already-bonded device re-runs bonding and forces a disconnect/reconnect cycle, and strands the menu at "(Pairing…)": the pair callback never fires for an already-connected device, and fetchConnectedPeripherals refuses to overwrite the in-flight .connecting, so macOS's own reconnect can't rescue the state. isPaired() separates the two reclaim semantics. A peripheral handed to the peer was `-remove`d (unregisterFromPC), so it is not bonded here and must be paired — the take-from-peer path, unchanged. A peripheral that merely dropped is still bonded, so it is ours: open the connection rather than re-pair. connectPeripheral now adopts/opens a bonded device for every caller, and the watcher skips the HOLDS_ONE peer query for a bonded device since the peer cannot hold one whose link key lives here. --- .../Store/BluetoothPeripheralStore.swift | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index becf92c..a540ae1 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -582,15 +582,29 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip return } - // Already connected — e.g. macOS reconnected this bonded peripheral on - // its own while the auto-reconnect watcher's HOLDS_ONE probe was in - // flight, so by the time we got here there's nothing left to pair. - // Starting an `IOBluetoothDevicePair` on an already-connected device - // never fires `devicePairingFinished`, stranding the UI at `.connecting` - // ("(Pairing…)"). Adopt the live connection instead. - if btDevice.isConnected() { - self.setConnectionState(.connected, for: peripheral.id) - self.registerForDisconnect(device: btDevice, address: peripheral.id) + // Already bonded to this Mac. A peripheral we're holding that merely + // dropped — power cycle, briefly out of range, wake — keeps its link + // key, so macOS reconnects it on its own. Running + // `IOBluetoothDevicePair.start()` on a bonded device re-runs bonding and + // forces a disconnect/reconnect cycle that fights that reconnect (and + // strands the UI at "(Pairing…)" — the pair callback never fires for an + // already-connected device, and `fetchConnectedPeripherals` won't + // overwrite the in-flight `.connecting`). So adopt the live connection, + // or just open one — never re-pair. A peripheral handed to the peer was + // `-remove`d (see `unregisterFromPC`), so it isn't bonded here and falls + // through to the pairing path below: that's the take-from-peer case. + if btDevice.isConnected() || btDevice.isPaired() { + if !btDevice.isConnected() { + _ = btDevice.openConnection() + } + if btDevice.isConnected() { + self.setConnectionState(.connected, for: peripheral.id) + self.registerForDisconnect(device: btDevice, address: peripheral.id) + } else { + // Bonded but didn't come up (still booting / out of range). Leave it + // disconnected; macOS or the watcher's next probe will bring it back. + self.setConnectionState(.disconnected, for: peripheral.id) + } return } @@ -979,8 +993,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } - /// Checks live IOBluetooth reachability for `peripheral` off the main queue; - /// if it's back in range, hands off to `reclaimIfPeerIsFree`. Marks the id + /// Checks live IOBluetooth state for `peripheral` off the main queue. If it's + /// already connected, adopts it; if it's back in range and still bonded here, + /// reconnects locally (it's ours); otherwise hands off to + /// `reclaimIfPeerIsFree` to consult the peer before pairing. Marks the id /// in-flight so overlapping ticks skip it until this resolves. private func probeAndReclaim(_ peripheral: BluetoothPeripheral) { let id = peripheral.id @@ -990,11 +1006,13 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // RSSI is the "is it back?" signal — `invalidRSSI` (127) means we can't // see it, the same gate `connectPeripheral` uses. Cheap while absent. var alreadyConnected = false + var bondedHere = false var reachable = false if IOBluetoothHostController.default().powerState != kBluetoothHCIPowerStateOFF, let device = IOBluetoothDevice(addressString: id) { alreadyConnected = device.isConnected() + bondedHere = device.isPaired() reachable = device.rssi() != Constants.invalidRSSI } DispatchQueue.main.async { [weak self] in @@ -1026,6 +1044,16 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip self.reconnectInFlight.remove(id) return } + if bondedHere { + // Still bonded to this Mac, so it's ours: the peer can't hold a + // device whose link key lives here. Skip the HOLDS_ONE query and + // reconnect locally — `connectPeripheral` opens the connection + // without re-pairing (re-pairing a bonded device forces a + // disconnect/reconnect cycle and fights macOS's own reconnect). + self.reconnectInFlight.remove(id) + self.connectPeripheral(peripheral, announcePairTimeout: false) + return + } self.reclaimIfPeerIsFree(peripheral) } }