From 714beedcc4b03b161ceecdb5fbbaf906d4206676 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Fri, 7 Aug 2026 17:32:51 +0200 Subject: [PATCH] fix: escalate an interactive connect to a bond refresh when a bonded device is in range but refuses to open A Magic device can sit at paired=true locally while it actually answers to the other Mac (a handoff done outside the app, or desynced state). An interactive no-peer take then hits the bonded branch, fails openConnection(), and reports 'Couldn't Connect' with no path that ever breaks the dead bond - the user is stuck until they unpair manually. Now, when the plain open fails and the RSSI probe can still see the device - alive and in range, yet refusing the bonded connect - the attempt removes the stale record and falls through to a fresh pair, the same refresh the peer-takeover path already performs. The probe gate keeps a healthy bond safe: a device that is merely off or out of range doesn't answer the probe, and its bond (and macOS's automatic reconnect) is left intact, failing exactly as before. Only the interactive entry point opts in; the background watcher and reclaim paths keep retrying the plain open so a transient link failure in a retry loop can't repeatedly tear bonds down. The remove-and-settle block is extracted into removeStaleBond, shared with the existing refreshPairingBeforeConnect path. --- .../Store/BluetoothPeripheralStore.swift | 78 ++++++++++++++----- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index fd715e5..ea1e615 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -1041,6 +1041,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip peripheral, announcePairTimeout: true, refreshPairingBeforeConnect: false, + refreshStaleBondOnFailedOpen: true, completion: nil ) } @@ -1071,6 +1072,16 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// pairing record before pairing. Use this only while taking a peripheral /// from the peer: Magic peripherals can sit at `paired=true` but refuse /// `openConnection()` until the target Mac re-pairs. + /// - Parameter refreshStaleBondOnFailedOpen: whether a bonded device that + /// refuses `openConnection()` while the RSSI probe can still see it may + /// have its local pairing record removed and re-paired within the same + /// attempt. That combination — alive and in range, yet refusing the + /// bonded connect — is the stale-bond signature: the local record says + /// `paired=true` but the device actually answers to the other Mac (a + /// handoff outside the app, or desynced state). Only interactive local + /// connects pass `true`; the background watcher/reclaim paths keep + /// retrying the plain open instead, so a transient link failure in a + /// retry loop can't repeatedly tear bonds down. /// - Parameter skipRangeCheck: start the pair even when the RSSI probe can't /// see the device. A peripheral we unpaired for sleep that nothing adopted /// is bonded to no Mac and invisible to the probe until the user touches @@ -1083,6 +1094,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip _ peripheral: BluetoothPeripheral, announcePairTimeout: Bool, refreshPairingBeforeConnect: Bool, + refreshStaleBondOnFailedOpen: Bool = false, skipRangeCheck: Bool = false, completion: ((Bool) -> Void)? ) { @@ -1130,22 +1142,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } if refreshPairingBeforeConnect, btDevice.isPaired() { - if btDevice.responds(to: Selector(("remove"))) { - btDevice.perform(Selector(("remove"))) - print("Removed stale local pairing before taking \(peripheral.name)") - // `-remove` tears the bond down asynchronously in the Bluetooth - // daemon; re-pairing before it settles can race the unbond and fail. - // A short fixed settle is simpler than a poll loop here (there's no - // condition to poll — just "give the daemon a moment"). We're on - // `bluetoothQueue`, a background serial queue, so this briefly stalls - // other queued BT work but never the main thread / UI. - Thread.sleep(forTimeInterval: 0.5) - if let refreshed = IOBluetoothDevice(addressString: peripheral.id) { - btDevice = refreshed - } - } else { - print("Cannot refresh stale pairing for \(peripheral.name): remove selector unavailable") - } + btDevice = self.removeStaleBond(of: btDevice, id: peripheral.id, name: peripheral.name) } // Already bonded to this Mac. A peripheral we're holding that merely @@ -1156,9 +1153,11 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // 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. For peer takeovers, a stale - // `paired=true connected=false` record is removed above so this branch - // does not mask the required re-pair. + // or just open one — don't re-pair up front. For peer takeovers, a + // stale `paired=true connected=false` record is removed above so this + // branch does not mask the required re-pair; interactive connects can + // instead escalate to that same refresh below, but only after the plain + // open has failed against a device the probe can still see. if !refreshPairingBeforeConnect, btDevice.isConnected() || btDevice.isPaired() { var openResult = kIOReturnSuccess if !btDevice.isConnected() { @@ -1167,13 +1166,27 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip if btDevice.isConnected() { self.setConnectionState(.connected, for: peripheral.id) self.registerForDisconnect(device: btDevice, address: peripheral.id) + return + } + print("openConnection to bonded \(peripheral.name) failed: \(openResult)") + if refreshStaleBondOnFailedOpen, + btDevice.responds(to: Selector(("remove"))), + btDevice.rssi() != Constants.invalidRSSI + { + // Alive and in range, yet refusing the bonded connect — the + // stale-bond signature (see the parameter doc). Break the dead + // record and fall through to a fresh pair. The RSSI gate is what + // makes this safe to do unprompted: a healthy bond whose device is + // merely off or out of range doesn't answer the probe, and removing + // *that* bond would cost the automatic reconnect macOS performs + // when the device comes back. + btDevice = self.removeStaleBond(of: btDevice, id: peripheral.id, name: peripheral.name) } else { // Bonded but didn't come up (still booting / out of range / link // failure). macOS or the watcher's next probe may still bring it // back, but an interactive Connect that lands here previously // discarded the openConnection result and looked like nothing // happened. - print("openConnection to bonded \(peripheral.name) failed: \(openResult)") self.failConnectAttempt( id: peripheral.id, name: peripheral.name, inline: "Couldn't connect.", @@ -1182,8 +1195,8 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip "Couldn't connect \(peripheral.name) (error \(openResult)). It may be off, out of range, or connected to your other Mac.", attempt: attempt ) + return } - return } if !skipRangeCheck, btDevice.rssi() == Constants.invalidRSSI { @@ -1241,6 +1254,29 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } + /// Removes a local pairing record judged stale so the caller can re-pair + /// from scratch. Runs on `bluetoothQueue` (it blocks in a settle sleep). + /// Returns a re-fetched device handle — the old one still reports the + /// removed bond. + private func removeStaleBond( + of btDevice: IOBluetoothDevice, id: String, name: String + ) -> IOBluetoothDevice { + guard btDevice.responds(to: Selector(("remove"))) else { + print("Cannot refresh stale pairing for \(name): remove selector unavailable") + return btDevice + } + btDevice.perform(Selector(("remove"))) + print("Removed stale local pairing before taking \(name)") + // `-remove` tears the bond down asynchronously in the Bluetooth + // daemon; re-pairing before it settles can race the unbond and fail. + // A short fixed settle is simpler than a poll loop here (there's no + // condition to poll — just "give the daemon a moment"). We're on + // `bluetoothQueue`, a background serial queue, so this briefly stalls + // other queued BT work but never the main thread / UI. + Thread.sleep(forTimeInterval: 0.5) + return IOBluetoothDevice(addressString: id) ?? btDevice + } + /// Disconnect device. Like `unregisterFromPC`, the IOBluetooth work runs on /// `bluetoothQueue` — `closeConnection` is synchronous bluetoothd IPC. func disconnectPeripheral(_ peripheral: BluetoothPeripheral) {