From 40db3ae383fc31d10d6e64918502771d9c78749e Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 11 Jun 2026 11:43:42 +0200 Subject: [PATCH 1/2] feat: adopt peripherals an absent peer left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mac only ever chased peripherals it was holding before its own sleep, so the 'peripherals follow the awake Mac' story had a missing half: when the holder slept (lid close, shutdown), nothing made the other Mac pick them up — it only ever appeared to work via the peripherals' own bond-level reconnection, which a prior handoff destroys. Arm the existing auto-reconnect watcher in a new *adoption* flavour for every registered peripheral not connected locally, from two triggers: - on wake, alongside the existing reclaim of the pre-sleep set (covers 'both lids closed, open the other one'), and - when the reachability poll sees a pinned peer miss two consecutive pings (~a minute) — covers 'the holder slept while this Mac was awake'. Adoption is deliberately more polite than reclaim, since this Mac has no prior claim: it takes only from a provably absent peer (two consecutive HOLDS_ONE probes failing at the connect layer), stands down the moment a live peer answers at all — an explicit 'not holding' included, so the prior holder's reclaim or the user outranks it and a simultaneous dual wake can't fight — and caps its pair attempts at 3, because a free Magic peripheral pairs on the first try while one held by an unreachable-but- awake peer just hangs the pairing. An explicit claim (drop, failed handoff, wake reclaim) upgrades an adoption entry to a full reclaim; an adoption sweep never downgrades an existing reclaim. With no trusted peer to consult (none registered, or TOFU mismatch), adoption stands down instead of reclaiming locally. --- .../Store/BluetoothPeripheralStore.swift | 154 ++++++++++++++++-- .../Model/Store/NetworkDeviceStore.swift | 20 +++ .../View/Settings/OtherSettingsView.swift | 2 +- 3 files changed, 160 insertions(+), 16 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 52b51bd..8aff79e 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -68,6 +68,18 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// stops a release whose disconnect notification never arrived from /// leaving a stale flag that suppresses a real reconnect. static let intentionalReleaseGrace: TimeInterval = 15 + /// Consecutive peer-absent `HOLDS_ONE` probes an *adoption* needs before + /// it takes a peripheral. Two probes (one extra tick) give Wi-Fi that's + /// still reassociating after wake a chance to come up — so a peer that's + /// actually alive gets to answer and stand the adoption down — while + /// keeping lid-open → peripheral-back under ~20s. + static let adoptionRequiredAbsentStreak = 2 + /// Failed local pair attempts after which an adoption gives up. A free + /// peripheral pairs on the first try; repeated failures usually mean it's + /// still held by a peer we can't reach over the network (pairing a held + /// Magic device just hangs), so bound the phantom "Pairing…" churn. + /// Reclaims — a prior claim — keep the full `reconnectMaxWindow` retry. + static let adoptionMaxPairAttempts = 3 } // MARK: - Dependencies @@ -178,9 +190,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// live without polling. private var globalConnectObserver: IOBluetoothUserNotification? - /// Peripherals the auto-reconnect watcher is trying to reclaim, keyed by - /// id, with the time each was armed (for the `reconnectMaxWindow` bound). - /// Main-only. + /// Peripherals the auto-reconnect watcher is trying to get onto this Mac, + /// keyed by id, with the time each was armed (for the `reconnectMaxWindow` + /// bound). An entry comes in one of two flavours: a *reclaim* (default — + /// this Mac has a prior claim: a genuine drop, a failed handoff, or a held + /// set being chased back after wake) or an *adoption* (no prior claim; see + /// `adoptionProgress`). Main-only. private var reconnectWatchlist: [String: Date] = [:] /// Ids with a probe/reclaim chain in flight, so overlapping ticks don't @@ -188,6 +203,26 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// the first is still resolving. Main-only. private var reconnectInFlight: Set = [] + /// Per-id bookkeeping for adoption arms; see `adoptionProgress`. + private struct AdoptionProgress { + /// Consecutive `HOLDS_ONE` probes that ended peer-absent (unreachable at + /// the TCP/connect layer). Reset implicitly: any answered probe stands + /// the adoption down instead. + var peerAbsentStreak = 0 + /// Local pair attempts made for this adoption so far. + var pairAttempts = 0 + } + + /// Watchlist entries armed as *adoption*: peripherals this Mac wasn't + /// holding (they lived on the peer) whose peer has dropped off the network + /// — slept, shut down, or left. Presence in this map is what distinguishes + /// an adoption from a reclaim. Adoption is deliberately more polite: it + /// takes a peripheral only from a *provably absent* peer (per + /// `continueAdoption`), stands down the moment a live peer answers at all + /// — "not holding" included, so a prior holder's reclaim or the user + /// outranks it — and caps its pair attempts. Main-only. + private var adoptionProgress: [String: AdoptionProgress] = [:] + /// Ids we released on purpose (handoff, "Remove from PC", sleep), each with /// the time it was flagged. The disconnect notification that follows within /// `Constants.intentionalReleaseGrace` must not arm the watcher — the @@ -347,17 +382,23 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// arms the watcher for *everything this Mac was holding before sleep* /// (`connectedBeforeSleep`) so it chases back whatever didn't return on its /// own — the watcher's probe applies the read-only `HOLDS_ONE` peer check, - /// so anything the peer legitimately took is left alone. When off, falls - /// back to the original one-shot reclaim of just the peripherals we released - /// for sleep. Waits `Constants.wakeReclaimDelay` first so the network can - /// reassociate (and bonded devices get a moment to reconnect on their own) - /// before any unreachable-looking peer gets a peripheral grabbed back. + /// so anything the peer legitimately took is left alone — and arms the + /// polite *adoption* flavour for the rest of the registered set: the peer + /// may have gone to sleep after this Mac did and left its peripherals + /// behind with no one to hand them over (it can't be asked to release once + /// it's unreachable). When off, falls back to the original one-shot reclaim + /// of just the peripherals we released for sleep. Waits + /// `Constants.wakeReclaimDelay` first so the network can reassociate (and + /// bonded devices get a moment to reconnect on their own) before any + /// unreachable-looking peer gets a peripheral grabbed back. private func reclaimPeripheralsAfterWake() { let connected = connectedBeforeSleep let released = peripheralsReleasedForSleep connectedBeforeSleep = [] peripheralsReleasedForSleep = [] - guard !connected.isEmpty else { return } + // Even with nothing held before sleep there can be work to do: the + // adoption sweep below picks up whatever an absent peer was holding. + guard !connected.isEmpty || (autoReconnect && !peripherals.isEmpty) else { return } // Connection states are stale across sleep — a peripheral we left bonded // still reads `.connected`. Refresh from live IOBluetooth so the watcher @@ -376,6 +417,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip guard self.peripherals.contains(where: { $0.id == id }) else { continue } self.armReconnect(id) } + // The rest of the registered set lived on the peer (or nowhere). If + // the peer is gone too, those peripherals are stranded — adopt them. + // Already-armed reclaims above are not downgraded by this sweep. + self.armAdoptionOfUnheldPeripherals() return } @@ -1160,28 +1205,57 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // MARK: - Auto-Reconnect Watcher + /// Arm the watcher in *adoption* mode for every registered peripheral not + /// currently connected to this Mac. Called when the peer stops being part + /// of the picture: this Mac just woke (the peer may have slept while we + /// did), or the reachability poll watched the peer drop off the network. + /// Arming broadly is safe because adoption only ever takes from a provably + /// absent peer (see `continueAdoption`): entries against a live peer stand + /// down on their first answered probe, and `armReconnect` never downgrades + /// an existing reclaim entry to an adoption. + func armAdoptionOfUnheldPeripherals() { + // Main-only state; called from the reachability poll's completion too. + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in self?.armAdoptionOfUnheldPeripherals() } + return + } + guard autoReconnect else { return } + for peripheral in peripherals where connectionState(for: peripheral.id) == .disconnected { + armReconnect(peripheral.id, adoption: true) + } + } + /// Arm the watcher for `id`: it'll be probed on the probe cadence and /// reclaimed once it's back in range and the peer isn't using it. No-op when /// the feature is off or the peripheral isn't registered to us. Preserves the /// original arm time on re-arm so the `reconnectMaxWindow` bound counts from - /// the first drop. - private func armReconnect(_ id: String) { + /// the first drop. `adoption` marks the polite no-prior-claim flavour; it + /// only applies to a *fresh* arm — re-arming an existing reclaim as an + /// adoption keeps the reclaim, while an explicit (non-adoption) re-arm + /// upgrades an adoption to a full reclaim. + private func armReconnect(_ id: String, adoption: Bool = false) { // The watcher dictionaries/sets and timer are main-only, but deliberate // releases (`unregisterFromPC` during a handoff) reach the watcher from the // outgoing-connection queue — hop to main so we never mutate this state // concurrently with `reconnectTick` / `handlePeripheralDisconnected`. guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in self?.armReconnect(id) } + DispatchQueue.main.async { [weak self] in self?.armReconnect(id, adoption: adoption) } return } guard autoReconnect, peripherals.contains(where: { $0.id == id }) else { return } if reconnectWatchlist[id] == nil { reconnectWatchlist[id] = Date() - print("Auto-reconnect: watching \(id)") + if adoption { adoptionProgress[id] = AdoptionProgress() } + print("Auto-reconnect: watching \(id)\(adoption ? " (adoption)" : "")") // If the timer is mid-interval, pull the next probe forward so this // newcomer is checked promptly rather than waiting out the rest of the // current interval. reconnectTimer?.schedule(deadline: .now(), leeway: Constants.reconnectProbeLeeway) + } else if !adoption { + // An explicit claim (genuine drop, failed handoff, wake reclaim) on an + // entry armed as adoption upgrades it: from here on, a live peer + // answering "not holding" no longer stands the watcher down. + adoptionProgress.removeValue(forKey: id) } startReconnectTimerIfNeeded() } @@ -1195,6 +1269,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip return } reconnectInFlight.remove(id) + adoptionProgress.removeValue(forKey: id) guard reconnectWatchlist.removeValue(forKey: id) != nil else { return } if reconnectWatchlist.isEmpty { stopReconnectTimer() } } @@ -1355,10 +1430,16 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip let device = NetworkDeviceStore.shared.networkDevices.first, device.pendingFingerprint == nil else { + reconnectInFlight.remove(id) + if adoptionProgress[id] != nil { + // No trusted peer to consult and no prior claim on the peripheral — + // stand down rather than grab one whose holder we can't even ask. + disarmReconnect(id) + return + } // No trusted peer to consult — none registered, or one flagged as a // TOFU identity mismatch. Either way it's ours; reclaim locally rather // than auto-probing an untrusted peer with our now-stale key. - reconnectInFlight.remove(id) connectPeripheral(peripheral, announcePairTimeout: false) return } @@ -1373,10 +1454,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // Peer is actively holding it — leave it there. print("Auto-reconnect: \(peripheral.name) held by \(device.name); leaving it") self.disarmReconnect(id) - case .failure: + case .failure(let failure): guard self.reconnectWatchlist[id] != nil, self.connectionState(for: id) == .disconnected else { return } + if self.adoptionProgress[id] != nil { + self.continueAdoption(of: peripheral, after: failure) + return + } print("Auto-reconnect: reclaiming \(peripheral.name)") self.connectPeripheral(peripheral, announcePairTimeout: false) } @@ -1384,6 +1469,45 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } + /// Adoption-flavoured continuation of `reclaimIfPeerIsFree`'s failure arm + /// (runs on main). A reclaim takes the peripheral on *any* `HOLDS_ONE` + /// failure; an adoption — no prior claim — takes it only once the peer is + /// provably absent: unreachable at the connect layer for + /// `adoptionRequiredAbsentStreak` consecutive probes. A peer that answers + /// at all — an explicit "not holding" (`.bodyFailed`) included — outranks + /// us, so stand down and leave the move to its reclaim or to the user. + /// Pair attempts are capped: a free peripheral pairs on the first try, so + /// repeated failures mean it's busy with a peer we can't reach. + private func continueAdoption(of peripheral: BluetoothPeripheral, after failure: OutgoingFailure) + { + let id = peripheral.id + guard var progress = adoptionProgress[id] else { return } + switch failure { + case .connectionFailed, .connectTimeout: + progress.peerAbsentStreak += 1 + default: + // The peer's machine accepted the TCP connection even though the probe + // failed past that point — that's a live peer, not an absent one. + print("Adoption: \(peripheral.name) — peer is up; standing down") + disarmReconnect(id) + return + } + guard progress.peerAbsentStreak >= Constants.adoptionRequiredAbsentStreak else { + adoptionProgress[id] = progress + return + } + guard progress.pairAttempts < Constants.adoptionMaxPairAttempts else { + print( + "Adoption: giving up on \(peripheral.name) after \(progress.pairAttempts) pair attempts") + disarmReconnect(id) + return + } + progress.pairAttempts += 1 + adoptionProgress[id] = progress + print("Adoption: taking \(peripheral.name) (attempt \(progress.pairAttempts))") + connectPeripheral(peripheral, announcePairTimeout: false) + } + // MARK: - Private Methods /// Reconcile registered peripheral names against the live paired-device list, diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index 8683810..dbfaf14 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -54,6 +54,12 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { private var reachabilityTimer: DispatchSourceTimer? private static let reachabilityInterval: TimeInterval = 30 + /// Consecutive failed `.ping` polls per device id (runtime only). Drives + /// the peer-vanished adoption trigger: one missed poll is routine (Wi-Fi + /// blip, mid-transition), two in a row (~a minute) is a peer that's + /// genuinely gone — asleep, shut down, off the network. Main-only. + private var consecutivePollFailures: [String: Int] = [:] + /// In-flight Ping/Sync per device id. Set when the user taps Ping/Sync on the /// Device tab and cleared when the op finishes; the view both disables the /// buttons and renders the "Pinging…/Syncing…" line off this, so they survive @@ -249,6 +255,20 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { if self.deviceReachability[device.id] != reachable { self.deviceReachability[device.id] = reachable } + if reachable { + self.consecutivePollFailures[device.id] = 0 + } else { + let failures = (self.consecutivePollFailures[device.id] ?? 0) + 1 + self.consecutivePollFailures[device.id] = failures + // Second consecutive miss: the peer has genuinely gone away, and + // whatever it was holding is stranded — let the adoption watcher + // pick it up. Exactly-two (not ≥) fires once per outage, so a + // long-dark peer doesn't re-arm the watcher every poll forever; + // a recovery resets the streak and re-arms it for the next one. + if failures == 2 { + BluetoothPeripheralStore.shared.armAdoptionOfUnheldPeripherals() + } + } } } } diff --git a/Magic Switch/View/Settings/OtherSettingsView.swift b/Magic Switch/View/Settings/OtherSettingsView.swift index 9d49aa7..0895f35 100644 --- a/Magic Switch/View/Settings/OtherSettingsView.swift +++ b/Magic Switch/View/Settings/OtherSettingsView.swift @@ -34,7 +34,7 @@ struct OtherSettingsView: View { Section { Toggle("Reconnect peripherals if they drop", isOn: $autoReconnect) .help( - "If a Magic peripheral that should be on this Mac drops — for example after closing the lid, or when you power-cycle a peripheral that got stuck — keep trying to reconnect it until it's back. Magic Switch won't take a peripheral your other Mac is actively using." + "If a Magic peripheral that should be on this Mac drops — for example after closing the lid, or when you power-cycle a peripheral that got stuck — keep trying to reconnect it until it's back. When your other Mac goes to sleep or drops off the network, this Mac also adopts the peripherals it left behind. Magic Switch won't take a peripheral your other Mac is actively using." ) } Section { From 89bc84cc6a57b3fdbe3f9540439846735d5763d1 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 11 Jun 2026 11:45:02 +0200 Subject: [PATCH 2/2] fix: count a ping-reachable peer as present for release-on-sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-on-sleep gate keyed solely off Bonjour's isActive, which is event-driven and persisted, so it goes stale in both directions: a Bonjour Sleep Proxy keeps a sleeping peer's records alive (stale true), and a withdrawn record / missed goodbye leaves an awake peer inactive (stale false) — making the release silently environment- and lid-order-dependent. Accept either presence signal: isActive, or the 30s .ping reachability poll's verdict. Also require the peer's identity pin to be clean — a TOFU-mismatched peer can't be commanded, so it's no one to hand off to. --- .../Store/BluetoothPeripheralStore.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 8aff79e..24dda78 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -318,11 +318,16 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// lid-close with no peer to hand it to and then won't reconnect (the /// macOS-side bug the watcher exists for). /// - /// 2. When `releaseOnSleep` is set and a peer looks present (paired + a - /// registered device we're seeing on Bonjour), release each held - /// peripheral so the peer can take it cleanly rather than have it - /// stranded on a Mac that can no longer be reached to release it. With - /// no peer around there's no one to hand off to, so we leave them bonded. + /// 2. When `releaseOnSleep` is set and a trusted peer looks present — + /// pinned identity, and either Bonjour-active or answering the `.ping` + /// reachability poll — release each held peripheral so the peer can + /// take it cleanly rather than have it stranded on a Mac that can no + /// longer be reached to release it. Either presence signal suffices: + /// `isActive` is event-driven and can go stale in both directions + /// (sleep proxies keep a sleeping peer's records alive; a missed mDNS + /// goodbye leaves a gone peer active), while the poll is fresh to ~30s. + /// With no peer around there's no one to hand off to, so we leave them + /// bonded. /// /// The IOBluetooth reads/removes run synchronously on `bluetoothQueue` (the /// only place IOBluetooth is touched) so they land before the radio powers @@ -335,10 +340,13 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip let registered = peripherals guard !registered.isEmpty else { return } + let networkStore = NetworkDeviceStore.shared let shouldRelease = releaseOnSleep && PairingStore.shared.isPaired - && NetworkDeviceStore.shared.networkDevices.contains(where: { $0.isActive }) + && networkStore.networkDevices.contains(where: { + $0.pendingFingerprint == nil && ($0.isActive || networkStore.isReachable($0.id)) + }) // If we're neither releasing nor going to chase peripherals on wake, skip // the IOBluetooth scan rather than block the (held) sleep transition to