Skip to content

fix: reconnect a bonded peripheral instead of re-pairing it#40

Merged
MegaManSec merged 1 commit into
mainfrom
fix/reconnect-bonded-without-repairing
Jun 3, 2026
Merged

fix: reconnect a bonded peripheral instead of re-pairing it#40
MegaManSec merged 1 commit into
mainfrom
fix/reconnect-bonded-without-repairing

Conversation

@MegaManSec
Copy link
Copy Markdown
Owner

Problem

A peripheral that drops while it is held on this Mac — power-cycling the keyboard, going briefly out of range, waking from sleep — stays bonded to this Mac (its link key is intact, since we never handed it off). macOS reconnects bonded devices on its own. But the auto-reconnect watcher (#36), catching the device reachable but not yet connected, ran IOBluetoothDevicePair.start() on it anyway.

Re-pairing an already-bonded device:

  • re-runs the bonding handshake, forcing a disconnect/reconnect cycle (the keyboard visibly drops and reconnects), and
  • strands the menu at "(Pairing…)" — the pair callback never fires for an already-connected device, and fetchConnectedPeripherals deliberately won'''t overwrite an in-flight .connecting, so macOS'''s own reconnect can'''t rescue the state.

Fix

isPaired() cleanly separates the two reclaim semantics:

  • Handed to the peerunregisterFromPC used the private -remove selector to delete the bond, so the device is not bonded here → it must be paired (the take-from-peer path, unchanged).
  • Merely dropped → still bonded here → it'''s ours → open the connection rather than re-pair.

Two layers:

  1. connectPeripheral adopts a live connection (or openConnection()s a bonded-but-disconnected one) instead of pairing whenever the device is already bonded — protecting every caller (watcher, wake-reclaim, interactive).
  2. The watcher'''s probeAndReclaim routes a bonded device straight to that gentle reconnect and skips the HOLDS_ONE peer query — the peer can'''t hold a device whose link key lives on this Mac.

Testing

  • xcodebuild ... CODE_SIGNING_ALLOWED=NO build succeeds; swift format lint clean.
  • Manual: restart the keyboard while it'''s held on this Mac — it should settle to connected with no "(Pairing…)" hang and no double drop/reconnect.

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.
@MegaManSec MegaManSec merged commit 145fe31 into main Jun 3, 2026
@MegaManSec MegaManSec deleted the fix/reconnect-bonded-without-repairing branch June 3, 2026 15:05
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

🎉 This PR is included in version 2.11.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

MegaManSec added a commit that referenced this pull request Jun 3, 2026
…onded (#41)

#40 let the auto-reconnect watcher skip the HOLDS_ONE peer check for a
peripheral still bonded to this Mac, on the premise that "the peer can't
hold a device whose link key lives here." That premise is wrong: Apple's
Magic devices remember multiple hosts and stay bonded to *both* Macs (the
README's setup step pairs each peripheral to both), so being paired here
says nothing about which Mac is currently connected.

The regression: if this Mac drops/sleeps while holding a peripheral and
the peer takes it during the downtime (the peer can't reach a sleeping
Mac to make it `-remove`, so the bond persists here), this Mac would wake
and reconnect-reclaim it without asking — yanking it back from the peer
that legitimately holds it.

Revert the skip so every reclaim goes through `reclaimIfPeerIsFree`
first. The gentle reconnect from #40 stays (connectPeripheral opens the
connection instead of re-pairing a bonded device), but it now only runs
after HOLDS_ONE confirms the peer is free — which is what actually fixed
the reported re-pair/disconnect loop.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant