From c7d0030f585e7f053fee13b8be1011953a2885a0 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 18 Jun 2026 20:55:46 +0200 Subject: [PATCH 1/3] feat: hand peripherals over promptly when the other Mac sleeps or vanishes - Proactive handoff on sleep: a Mac releasing peripherals as it sleeps now pushes the freed set to a reachable peer (new ADOPT_RELEASED opcode, acked on receipt) so they land on the awake Mac immediately instead of waiting to be detected gone. Best-effort; falls back to reactive adoption. - Release on sleep whenever a trusted peer is registered, not only when it is reachable that instant, so a peripheral is never left latched to a sleeping host and the other Mac can take it on its next wake without a power-cycle. - Detect a vanished peer fast while this Mac is awake: a first missed reachability poll (or a Bonjour withdraw) schedules a quick confirming re-probe instead of waiting out the 30s interval, arming adoption in seconds. - Full-set menu switch grabs locally and arms the auto-reconnect watcher when the peer is unreachable, mirroring the per-peripheral takeover, instead of erroring and stranding the peripherals. --- Magic Switch/AppDelegate/AppDelegate.swift | 50 +++++-- Magic Switch/Manager/IncomingConnection.swift | 31 +++- .../Store/BluetoothPeripheralStore.swift | 84 +++++++++-- .../Model/Store/NetworkDeviceStore.swift | 136 +++++++++++++++--- 4 files changed, 250 insertions(+), 51 deletions(-) diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 7ae85c5..352fd3a 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -444,20 +444,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // nothing has changed locally yet. beginTransfer(.receiving) networkStore.executeCommand(.unregisterAll, on: device) { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - self.bluetoothStore.peripherals.forEach { peripheral in - self.bluetoothStore.connectPeripheralFromPeer(peripheral) - } + // `executeCommand`'s completion fires on the outgoing-connection queue; + // hop to main before touching the status-bar icon or the stores. + DispatchQueue.main.async { + guard let self = self else { return } self.endTransfer() - case .failure(let err): - self.endTransfer() - NotificationManager.showNotification( - title: "Switch Failed", - body: err.userMessage, - identifier: "switch-disconnect-remote-failed" - ) + switch result { + case .success, .failure(.connectionFailed), .failure(.connectTimeout): + // Either the peer released everything (success), or we couldn't + // reach it at all — in which case its machine is unreachable + // (asleep, off the network, app not running) and it isn't holding + // the peripherals anymore, since a Mac that drops off the network + // has already released its Bluetooth devices. Both ways the + // peripherals are free: grab them locally instead of stranding the + // user with an error they can't act on, and arm the auto-reconnect + // watcher as the retry safety net for any device stuck in the + // bonded-but-not-connected state that needs a power-cycle. Mirrors + // `takePeripheralFromPeer`'s success + unreachable arms, at full-set + // scope. + self.bluetoothStore.peripherals.forEach { peripheral in + self.bluetoothStore.connectPeripheralFromPeer(peripheral) + self.bluetoothStore.armReconnectForTakeover(peripheral.id) + } + case .failure(let err): + // Reachable peer but the release-all errored, so we can't be sure + // it let go. Don't grab outright (that could yank a peripheral from + // a peer that didn't release); arm the HOLDS_ONE-gated watcher, + // which reclaims each one only once the peer confirms it isn't + // holding it — and recovers the case where the peer released but + // the ack was lost. + self.bluetoothStore.peripherals.forEach { peripheral in + self.bluetoothStore.armReconnectForTakeover(peripheral.id) + } + NotificationManager.showNotification( + title: "Switch Failed", + body: err.userMessage, + identifier: "switch-disconnect-remote-failed" + ) + } } } case .partial: diff --git a/Magic Switch/Manager/IncomingConnection.swift b/Magic Switch/Manager/IncomingConnection.swift index 53d7dc5..a811d69 100644 --- a/Magic Switch/Manager/IncomingConnection.swift +++ b/Magic Switch/Manager/IncomingConnection.swift @@ -189,7 +189,7 @@ final class IncomingConnection { private func handleCommand(_ command: DeviceCommand) { lastReceivedCommand = command switch command { - case .notification, .syncPeripherals, .unregisterOne, .connectOne, .holdsOne: + case .notification, .syncPeripherals, .unregisterOne, .connectOne, .holdsOne, .adoptReleased: // Two-frame commands; data frame handled in `handleCommandData`. break case .connectAll: @@ -345,6 +345,35 @@ final class IncomingConnection { (held ? DeviceCommand.operationSuccess : DeviceCommand.operationFailed).rawValue) } } + case .adoptReleased: + // Comma-separated MACs the peer released as it went to sleep. Take the + // ones we have registered — a proactive handoff, so they arrive here at + // once instead of via reactive adoption. Validate every entry before + // touching the store with peer-supplied input, and cap the list. Ack on + // receipt (we don't make the sleeping peer wait out pairing) and run the + // grab async; `connectPeripheralFromPeer` no-ops on anything we already + // hold, and the watcher (`armReconnectForTakeover`) covers a device + // that's briefly stuck and needs a power-cycle. + let macs = message.split(separator: ",").map(String.init) + guard !macs.isEmpty, macs.count <= 64, macs.allSatisfy(Self.isValidMACAddress) else { + print("adoptReleased: empty, oversized, or malformed address list") + sendString(DeviceCommand.operationFailed.rawValue) + break + } + let store = bluetoothStore + DispatchQueue.main.async { + let toTake = macs.compactMap { mac in store.peripherals.first(where: { $0.id == mac }) } + guard !toTake.isEmpty else { return } + // One arrow flash for the batch (receiving direction). + NotificationCenter.default.post(name: .magicSwitchPeripheralIncoming, object: nil) + for peripheral in toTake { + store.connectPeripheralFromPeer(peripheral) + store.armReconnectForTakeover(peripheral.id) + } + } + // Acked on receipt: the goal ("you now own these") is recorded even if a + // given peripheral isn't registered here or needs a retry to connect. + sendString(DeviceCommand.operationSuccess.rawValue) default: break } diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index b2f146b..0b55775 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -45,6 +45,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// peer that's actively using the peripheral doesn't look unreachable and /// get it yanked back. static let wakeReclaimDelay: TimeInterval = 5 + /// Upper bound on how long `prepareForSleep` blocks the (held) sleep + /// transition waiting for the peer to ack the proactive handoff push (see + /// `prepareForSleep`). A present peer acks in well under a second; the cap + /// keeps a peer that vanished in the same instant from delaying sleep more + /// than briefly. Stays well inside the OS's ~30s power-handler watchdog. + static let sleepHandoffAckTimeout: TimeInterval = 3 /// How often the auto-reconnect watcher probes a dropped peripheral to see /// whether it's back. The probe is just an RSSI read while the device is /// absent, so it's cheap. A short, *constant* cadence is deliberate: the @@ -326,16 +332,21 @@ 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 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. + /// 2. When `releaseOnSleep` is set and a trusted (non-mismatched) peer is + /// *registered*, release each held peripheral — so it's freed rather than + /// left latched to a host that's about to be unreachable, and the other + /// Mac can take it on its next wake without a power-cycle. We deliberately + /// do *not* require the peer to be reachable this instant: if it is, we + /// also push it the released set so the handoff is immediate (job 3); if it + /// isn't (asleep, off the network), freeing the peripheral still lets that + /// Mac adopt it whenever it wakes, and our own `reclaimPeripheralsAfterWake` + /// brings back anything it didn't take. A lone Mac with no registered peer + /// keeps its bond — nothing to hand to, and re-pairing on every wake would + /// be pure churn. + /// + /// 3. If a trusted peer is reachable right now, proactively push it the + /// released set (`executeAdoptReleased`) so it grabs them immediately + /// instead of waiting to notice we're gone. Best-effort; see below. /// /// The IOBluetooth reads/removes run synchronously on `bluetoothQueue` (the /// only place IOBluetooth is touched) so they land before the radio powers @@ -349,12 +360,25 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip guard !registered.isEmpty else { return } let networkStore = NetworkDeviceStore.shared + // A trusted (non-mismatched) peer is *registered*, whether or not it's + // reachable this instant. + let hasTrustedPeer = networkStore.networkDevices.contains { $0.pendingFingerprint == nil } + // The subset of that which is reachable *now* — the target for the + // proactive push below. + let presentPeer = networkStore.networkDevices.first(where: { + $0.pendingFingerprint == nil && ($0.isActive || networkStore.isReachable($0.id)) + }) + // Release whenever a two-Mac handoff is configured — not only when the peer + // is reachable this instant. Freeing the peripheral as we sleep means it's + // never left latched to a sleeping host, so the other Mac can take it on its + // next wake without a power-cycle; if the peer is unreachable now we simply + // can't *push* (below) and it adopts on its own wake instead. A lone Mac + // with no registered peer keeps its bond — there's nothing to hand to, and + // re-pairing it on every wake would be pure churn. The wake reclaim brings + // back whatever the peer didn't take (HOLDS_ONE-gated, so the two Macs never + // fight over it). let shouldRelease = - releaseOnSleep - && PairingStore.shared.isPaired - && networkStore.networkDevices.contains(where: { - $0.pendingFingerprint == nil && ($0.isActive || networkStore.isReachable($0.id)) - }) + releaseOnSleep && PairingStore.shared.isPaired && hasTrustedPeer // If we're neither releasing nor going to chase peripherals on wake, skip // the IOBluetooth scan rather than block the (held) sleep transition to @@ -392,6 +416,24 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip if !connectedIDs.isEmpty { print("Before sleep: \(connectedIDs.count) connected, released \(releasedIDs.count)") } + + // Proactive handoff: we've just freed these locally, so ask the present + // peer to take them right now instead of leaving it to notice we're gone + // and adopt them. This is what makes the handoff feel immediate when the + // other Mac is awake. Best-effort and layered on top of the release above + // (which already happened): if the push is missed, the peer's reactive + // adoption still recovers them. We briefly block the (held) sleep + // transition for the receipt ack — once this returns the radio powers down + // and any un-flushed frame is lost — but sleep anyway if it doesn't arrive + // within the budget. The ack fires on a background queue, so blocking main + // here can't deadlock the send. + if let peer = presentPeer, !releasedIDs.isEmpty { + let ackWait = DispatchSemaphore(value: 0) + networkStore.executeAdoptReleased(addresses: releasedIDs, on: peer) { _ in + ackWait.signal() + } + _ = ackWait.wait(timeout: .now() + Constants.sleepHandoffAckTimeout) + } } /// Runs (on main) from `SleepMonitor` after wake. When auto-reconnect is on, @@ -1392,6 +1434,18 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip startReconnectTimerIfNeeded() } + /// Arm the auto-reconnect watcher for `id` as a *reclaim* (prior claim) — + /// the same retry/rollback safety net `takePeripheralFromPeer` arms + /// internally. Exposed for `AppDelegate`'s full-set takeover, which drives + /// the status-bar transfer icon itself and so can't route through + /// `takePeripheralFromPeer`. A reclaim is HOLDS_ONE-gated (it never grabs a + /// peripheral the peer confirms it's holding) and retries for the full + /// `reconnectMaxWindow`, so a device stuck in the bonded-but-not-connected + /// state comes back the moment the user power-cycles it. + func armReconnectForTakeover(_ id: String) { + armReconnect(id) + } + /// Stop watching `id` — it connected, moved to the peer, was removed, or /// timed out. Tears the timer down once nothing is left to watch. private func disarmReconnect(_ id: String) { diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index fa6ce29..850262d 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -53,6 +53,12 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { @Published private(set) var deviceReachability: [String: Bool] = [:] private var reachabilityTimer: DispatchSourceTimer? private static let reachabilityInterval: TimeInterval = 30 + /// Delay before the fast off-cycle recheck that confirms a *first* missed + /// poll (see `scheduleFastReachabilityRecheck`). Short enough to collapse the + /// ~30s worst-case detection latency that otherwise leaves a vanished peer's + /// peripherals unclaimed while this Mac is awake; long enough that a single + /// dropped packet clears on the retry rather than arming adoption. + private static let fastRecheckDelay: TimeInterval = 3 /// Body-read timeout for the two commands whose receiver only acks after it /// has actually finished (re-)pairing — `CONNECT_ALL` and `CONNECT_ONE` — @@ -66,10 +72,17 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { /// 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. + /// blip, mid-transition), two in a row is a peer that's genuinely gone — + /// asleep, shut down, off the network. The second miss is now reached in a + /// few seconds (see `scheduleFastReachabilityRecheck`) rather than across two + /// 30s polls. Main-only. private var consecutivePollFailures: [String: Int] = [:] + /// Devices with a fast off-cycle reachability recheck already scheduled, so + /// overlapping triggers (a missed poll and a Bonjour withdraw landing + /// together) don't stack up multiple rechecks. Main-only. + private var pendingFastRecheck: Set = [] + /// 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 @@ -209,6 +222,21 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { // Mirror Bonjour's verdict into reachability (a withdraw is a valid, if // slow, offline signal); the `.ping` poll provides the fast path. deviceReachability[id] = isActive + + // A Bonjour withdraw is a *hint* the peer left, not proof — it can be an + // mDNS flap while the peer is still reachable, and (behind a Bonjour sleep + // proxy) a genuinely sleeping peer may not withdraw at all. So don't wait + // out a full poll interval: confirm now. If the peer answers, the probe + // re-marks it reachable; if it doesn't, this lands as the first miss and + // the fast-recheck path arms adoption within seconds — closing the gap + // where a peer that vanished while this Mac was awake left its peripherals + // unclaimed for ~a minute. Guard `isPaired` so the probe can't book a + // spurious `.notPaired` failure into the streak. + if !isActive, PairingStore.shared.isPaired, + let device = networkDevices.first(where: { $0.id == id && $0.pendingFingerprint == nil }) + { + probeReachability(of: device) + } } // MARK: - Reachability @@ -251,39 +279,80 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { // `.ping` rides the secure channel, so it's meaningless unpaired — skip // (leaving the pessimistic default) rather than spam `.notPaired` failures. // Skip mismatched peers too: a `.ping` with our old key would just auth-fail - // and feed the peer's inbound rate limiter. `countsTowardRateLimit: false` - // keeps these fixed-cadence probes from tripping our own outbound limiter. + // and feed the peer's inbound rate limiter. guard PairingStore.shared.isPaired else { return } for device in networkDevices where device.pendingFingerprint == nil { - executeCommand(.ping, on: device, countsTowardRateLimit: false) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - let reachable: Bool - if case .success = result { reachable = true } else { reachable = false } - // Publish only on change — a steady-state poll would otherwise fire - // objectWillChange every interval and needlessly re-render observers. - 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 + probeReachability(of: device) + } + } + + /// One reachability probe against `device`: updates `deviceReachability` + /// (which greys the menu/Device-tab rows) and advances the + /// `consecutivePollFailures` streak that triggers peer-vanished adoption. + /// `countsTowardRateLimit: false` keeps these fixed-cadence probes from + /// tripping our own outbound limiter. Factored out of `pollReachability` so a + /// single device can be re-probed off-cycle — by the fast confirming recheck + /// below, and by a Bonjour withdraw — without waiting out the 30s interval. + private func probeReachability(of device: NetworkDevice) { + executeCommand(.ping, on: device, countsTowardRateLimit: false) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + let reachable: Bool + if case .success = result { reachable = true } else { reachable = false } + // Publish only on change — a steady-state poll would otherwise fire + // objectWillChange every interval and needlessly re-render observers. + 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 + if failures == 1 { + // First miss of a new outage. The peer may have just left (slept, + // unplugged, quit); confirm with one quick re-probe rather than + // waiting a full ~30s interval for the next scheduled poll. A + // one-off dropped packet is cleared when the confirm succeeds and + // resets the streak. This is what makes a peer that vanishes *while + // this Mac is already awake* get its peripherals adopted within + // seconds instead of ~a minute — the wake path already covers the + // case where this Mac was itself asleep. + self.scheduleFastReachabilityRecheck(of: device) + } else if failures == 2 { // 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() - } + BluetoothPeripheralStore.shared.armAdoptionOfUnheldPeripherals() } } } } } + /// Schedule a single off-cycle reachability re-probe of `device` a few + /// seconds out, to *confirm* a first missed poll (or a Bonjour withdraw) + /// quickly. Deduplicated per device so overlapping triggers don't stack + /// rechecks; the probe's own failure counting decides whether to arm + /// adoption. Runs on main. + private func scheduleFastReachabilityRecheck(of device: NetworkDevice) { + guard !pendingFastRecheck.contains(device.id) else { return } + pendingFastRecheck.insert(device.id) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.fastRecheckDelay) { [weak self] in + guard let self = self else { return } + self.pendingFastRecheck.remove(device.id) + // Re-probe only if it's still a registered, paired, non-mismatched peer. + guard PairingStore.shared.isPaired, + let current = self.networkDevices.first(where: { + $0.id == device.id && $0.pendingFingerprint == nil + }) + else { return } + self.probeReachability(of: current) + } + } + func sendNotification( to device: NetworkDevice, completion: ((Result) -> Void)? = nil @@ -400,6 +469,14 @@ enum DeviceCommand: String, Codable { /// does. Lets the switch action bail out before touching local Bluetooth /// state if the peer can't actually take a command right now. case ping = "PING" + /// Two-frame: opcode then a comma-separated list of MAC addresses the + /// *sender* just released as it goes to sleep. The receiver acks on receipt + /// (like `UNREGISTER_ALL`, not after pairing — the sleeping sender can't wait + /// that long) and then takes those peripherals. A proactive handoff, so a + /// peripheral lands on the awake Mac immediately instead of waiting for the + /// sleeping peer to be detected gone. Best-effort: the sender has already + /// released locally, so a dropped push just falls back to reactive adoption. + case adoptReleased = "ADOPT_RELEASED" } // MARK: - Health Check Extension @@ -631,6 +708,21 @@ extension NetworkDeviceStore { completion: completion) } + /// Tells `device` to take the peripherals the sender just released on its way + /// to sleep — the proactive handoff `prepareForSleep` fires. The peer acks on + /// receipt, so the sleeping sender only has to wait a moment before sleeping. + /// `countsTowardRateLimit: false` — this is a system-triggered push, not a + /// user action, and shouldn't feed the limiter that gates real switches. + func executeAdoptReleased( + addresses: [String], + on device: NetworkDevice, + completion: @escaping (Result) -> Void + ) { + sendTwoFrameCommand( + .adoptReleased, payload: addresses.joined(separator: ","), to: device, + countsTowardRateLimit: false, completion: completion) + } + /// Shared helper for "opcode + single payload frame, await OP_SUCCESS". /// Kept private to this extension; the older two-frame call sites /// (`sendNotificationOverSecure`, `sendPeripheralSync`) still have their From 965c17756717b1a4e148a07a9ab5a98e3d7c0f4f Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 18 Jun 2026 20:55:46 +0200 Subject: [PATCH 2/3] docs: explain the menu screenshot's checkmark callout The annotated menu.png carries a checkmark callout ("it's on this Mac now") that, unlike every other screenshot's callouts, was not explained beside the image (only in the Usage table). Complete the caption so it covers all three. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b001bc..6fbbe37 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This is a security-hardened fork of [HoshimuraYuto/blue-switch](https://github.c

The Magic Switch menu-bar dropdown
- It lives in the menu bar: click a Mac to hand over everything, or a single peripheral to move just that one. + It lives in the menu bar: click a Mac to move every peripheral to it, or a single peripheral to move just that one. A checkmark marks whatever's on this Mac right now.

## Installation From 22fa026d236bb89cc3ee838451ecbe781a8b3140 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 18 Jun 2026 21:08:28 +0200 Subject: [PATCH 3/3] docs: explain the release-on-sleep toggle in Troubleshooting The "Release peripherals when this Mac sleeps" toggle was named in the Other tab but never explained, and the Other-tab "see Troubleshooting" pointer only resolved to the reconnect toggle's writeup. Add a user-level Troubleshooting bullet describing the sleep hand-off (and freeing peripherals for a peer that isn't reachable yet), without the internal mechanics. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6fbbe37..1eff444 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Magic Switch tells you when there's a new version — it never updates itself. A - Bluetooth and Local Network permissions granted in System Settings → Privacy & Security. - A **greyed-out device** — in the Device tab or the right-click menu — means it isn't reachable on the network right now (the other Mac is asleep, off Wi-Fi, or not running Magic Switch). Ping, Sync, and switching stay disabled until it's back online. - On the **Device** tab, **Ping** tests whether the two Macs can reach each other over the secure channel. +- **Closing or sleeping one Mac hands its peripherals to the other.** When this Mac sleeps (or you close its lid), it hands the peripherals it holds to your other Mac — or, if that Mac isn't reachable yet, frees them so it can pick them up the moment it wakes. That's why you can close one Mac and find the keyboard and mouse already on the other. This is on by default; you can turn it off under **Settings → Other → "Release peripherals when this Mac sleeps."** - **A peripheral didn't come back after sleep or a lid-close.** Apple's Magic devices sometimes get stuck once the Bluetooth radio sleeps and won't reconnect — even a manual reconnect fails until you switch the peripheral **off and on** with its power switch. Magic Switch keeps watching for anything that was on this Mac before it slept: the moment the device reappears (which a power-cycle triggers), it reconnects automatically — as long as your other Mac isn't actively using it. This is on by default; you can turn it off under **Settings → Other → "Reconnect peripherals if they drop."** ## Developer notes