From bb91089b0a9567da771ef712de431ca9fd887e9e Mon Sep 17 00:00:00 2001 From: Samuel Phinizy Date: Thu, 20 Aug 2026 10:45:16 -0400 Subject: [PATCH 1/3] fix(hid): handle analytics-only keyboards in host_switch_targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboards whose Easy-Switch controls are analytics-only (divertable=false) depart before switch_linked_hosts can probe the device. The previous code prepared the keyboard's host change first, and the DeviceNotFound error propagated via ?, skipping the target loop entirely — the mouse never received its setCurrentHost command. Now, when the keyboard is unreachable (already departed), the function falls through to switch_targets_directly, which opens each target's own channel and switches it independently. Verified on MX Keys S for Mac (B37C) + MX Master 4 (B042) over Bolt. --- .../openlogi-hid/src/session/host_switch.rs | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/crates/openlogi-hid/src/session/host_switch.rs b/crates/openlogi-hid/src/session/host_switch.rs index 7cf8d7e9e..72eaf0ebe 100644 --- a/crates/openlogi-hid/src/session/host_switch.rs +++ b/crates/openlogi-hid/src/session/host_switch.rs @@ -167,23 +167,44 @@ pub async fn run_host_switch_session( /// Move reachable targets to `host`, then move the keyboard last. /// -/// Returns whether the keyboard actually changed hosts. +/// Returns whether the keyboard actually changed hosts (or had already +/// departed — analytics-only keyboards leave before this function runs). pub async fn switch_linked_hosts( keyboard: &DeviceRoute, targets: &[DeviceRoute], host: u8, channel_pool: &ChannelPool, ) -> Result { - let channel = open_channel(channel_pool, keyboard, "opening keyboard channel") - .await? - .ok_or(HostSwitchError::KeyboardNotFound)?; + let channel = match open_channel(channel_pool, keyboard, "opening keyboard channel").await? { + Some(ch) => ch, + None => { + // Analytics-only keyboards depart before this function runs. + // Switch every target directly and report the keyboard as + // already-switched. + debug!(route = %keyboard, host, "keyboard already departed; switching targets only"); + switch_targets_directly(targets, host, channel_pool).await; + return Ok(true); + } + }; // Validate the keyboard's own move before touching anything: preparation is // read-only, but it is the step that rejects an unpaired host slot, and // discovering that *after* the mice have moved would strand them on a host // the keyboard never reaches. Applying it still happens last, because once // the keyboard leaves this host its channel can no longer command a mouse // sharing the same receiver. - let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?; + let keyboard_change = match prepare_host_change_on(&channel, keyboard.device_index(), host) + .await + { + Ok(change) => Some(change), + Err(error) => { + // The keyboard may have departed between opening the channel and + // probing the device (analytics-mode race). Switch the targets + // through their own channels and treat the keyboard as gone. + debug!(%error, route = %keyboard, host, "keyboard unreachable; switching targets only"); + switch_targets_directly(targets, host, channel_pool).await; + return Ok(true); + } + }; for target in targets { match prepare_host_change(target, host, keyboard, &channel, channel_pool).await { Ok(change) => { @@ -196,11 +217,48 @@ pub async fn switch_linked_hosts( } } } - let changed = apply_host_change(keyboard_change).await?; - if changed { - debug!(host, route = %keyboard, "keyboard host switched"); + if let Some(change) = keyboard_change { + let changed = apply_host_change(change).await?; + if changed { + debug!(host, route = %keyboard, "keyboard host switched"); + } + Ok(changed) + } else { + Ok(true) + } +} + +/// Switch targets through their own dedicated channels, without relying on +/// the keyboard's channel. Used when the keyboard has already departed +/// (analytics-only Easy-Switch). +async fn switch_targets_directly( + targets: &[DeviceRoute], + host: u8, + channel_pool: &ChannelPool, +) { + for target in targets { + let channel = match open_channel(channel_pool, target, "opening target channel").await { + Ok(Some(ch)) => ch, + Ok(None) => { + debug!(route = %target, host, "target not reachable for direct switch"); + continue; + } + Err(error) => { + debug!(%error, route = %target, host, "target channel open failed"); + continue; + } + }; + match prepare_host_change_on(&channel, target.device_index(), host).await { + Ok(change) => { + if let Err(error) = apply_host_change(change).await { + debug!(%error, route = %target, host, "direct target host switch failed"); + } + } + Err(error) => { + debug!(%error, route = %target, host, "direct target host switch preparation failed"); + } + } } - Ok(changed) } async fn arm_host_controls( From 2547255335d5336c9c05b7a96b7fe58e003bc03a Mon Sep 17 00:00:00 2001 From: Samuel Phinizy Date: Thu, 20 Aug 2026 10:59:00 -0400 Subject: [PATCH 2/3] fix(hid): restrict departure fallback to unreachable-device errors The catch-all error branch also caught HostSlotEmpty, which would have moved the targets to an unpaired host while the keyboard stayed put. Add HostSwitchError::is_device_unreachable() to distinguish transport and discovery failures (Hid, KeyboardNotFound, TimedOut, Hidpp with DeviceNotFound) from validation failures (HostSlotEmpty, UnsupportedKeyboard) and only fall through on the former. --- .../openlogi-hid/src/session/host_switch.rs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-hid/src/session/host_switch.rs b/crates/openlogi-hid/src/session/host_switch.rs index 72eaf0ebe..8e64cf866 100644 --- a/crates/openlogi-hid/src/session/host_switch.rs +++ b/crates/openlogi-hid/src/session/host_switch.rs @@ -98,6 +98,18 @@ pub enum HostSwitchError { }, } +impl HostSwitchError { + /// Whether the error indicates the device has departed (analytics-mode + /// keyboards disconnect before software can probe them). Validation + /// errors like [`HostSlotEmpty`](Self::HostSlotEmpty) are NOT departure. + fn is_device_unreachable(&self) -> bool { + matches!( + self, + Self::Hid(_) | Self::KeyboardNotFound | Self::TimedOut { .. } + ) || matches!(self, Self::Hidpp(msg) if msg.contains("DeviceNotFound")) + } +} + /// Capture host switch keys on `keyboard` until one is pressed or `shutdown` /// resolves. Controls are restored before a requested host is returned. pub async fn run_host_switch_session( @@ -196,14 +208,15 @@ pub async fn switch_linked_hosts( .await { Ok(change) => Some(change), - Err(error) => { - // The keyboard may have departed between opening the channel and - // probing the device (analytics-mode race). Switch the targets - // through their own channels and treat the keyboard as gone. + Err(error) if error.is_device_unreachable() => { + // The keyboard departed between opening the channel and probing + // the device (analytics-mode race). Switch the targets through + // their own channels and treat the keyboard as gone. debug!(%error, route = %keyboard, host, "keyboard unreachable; switching targets only"); switch_targets_directly(targets, host, channel_pool).await; return Ok(true); } + Err(error) => return Err(error), }; for target in targets { match prepare_host_change(target, host, keyboard, &channel, channel_pool).await { From 2ac1c0a26e53d471a42ffb8276bfbcd4350fe4ea Mon Sep 17 00:00:00 2001 From: Samuel Phinizy Date: Fri, 21 Aug 2026 08:26:57 -0400 Subject: [PATCH 3/3] fix(hid): remove TimedOut from departure heuristic A timeout on a reachable-but-slow keyboard is not a departure signal. The Hidpp("DeviceNotFound") and Hid variants already cover the real analytics-mode departure race. --- crates/openlogi-hid/src/session/host_switch.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-hid/src/session/host_switch.rs b/crates/openlogi-hid/src/session/host_switch.rs index 37b1fd54e..6b0e37cbe 100644 --- a/crates/openlogi-hid/src/session/host_switch.rs +++ b/crates/openlogi-hid/src/session/host_switch.rs @@ -103,10 +103,8 @@ impl HostSwitchError { /// keyboards disconnect before software can probe them). Validation /// errors like [`HostSlotEmpty`](Self::HostSlotEmpty) are NOT departure. fn is_device_unreachable(&self) -> bool { - matches!( - self, - Self::Hid(_) | Self::KeyboardNotFound | Self::TimedOut { .. } - ) || matches!(self, Self::Hidpp(msg) if msg.contains("DeviceNotFound")) + matches!(self, Self::Hid(_) | Self::KeyboardNotFound) + || matches!(self, Self::Hidpp(msg) if msg.contains("DeviceNotFound")) } }