Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 135 additions & 3 deletions crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::hook_runtime::{HookMaps, SharedHookMaps};
use crate::ipc::InventoryHealth;
use crate::receiver_access::ReceiverAccess;
use crate::watchers::gesture::GestureBindings;
use crate::watchers::host_switch::{HostSwitchLink, HostSwitchLinks};

/// The minimal per-device facts the agent needs: the config key (binding /
/// preset lookup), the HID++ route (DPI/SmartShift writes + capture target), and
Expand Down Expand Up @@ -61,6 +62,8 @@ pub struct SharedRuntime {
/// Exclusive receiver access shared by HID++ capture and pairing. Capture
/// and pairing must never open the same receiver HID node concurrently.
pub receiver_access: ReceiverAccess,
/// Keyboard → pointing-device routes resolved from `config.toml`.
pub host_switch_links: HostSwitchLinks,
}

/// Owns the config + device selection and keeps [`SharedRuntime`] in sync.
Expand Down Expand Up @@ -112,6 +115,7 @@ impl Orchestrator {
)),
capture_channel: Arc::new(RwLock::new(None)),
receiver_access: ReceiverAccess::default(),
host_switch_links: Arc::new(RwLock::new(Vec::new())),
};
let orch = Self {
config,
Expand Down Expand Up @@ -140,7 +144,29 @@ impl Orchestrator {
}

fn current_route(&self) -> Option<DeviceRoute> {
self.devices.get(self.current).and_then(|d| d.route.clone())
self.devices
.get(self.current)
.filter(|device| device.online)
.and_then(|device| device.route.clone())
}

/// Keep the capture/DPI write target aligned with the selected device's
/// live connection state without rebuilding the rest of the DPI cycle.
///
/// Inventory-only online transitions do not warrant [`Self::rebuild`]
/// (which intentionally resets the cycle index), but they do have to stop
/// and restart HID++ capture. Easy-Switch preserves the receiver route
/// while the device is away, and its volatile control diversion is lost;
/// publishing `None` while offline and the route again on return makes the
/// capture watcher open a fresh session and re-arm those controls.
fn sync_current_route(&self) {
let target = self.current_route();
match self.shared.dpi_cycle.write() {
Ok(mut state) => state.target = target,
Err(error) => {
warn!(%error, lock = "dpi_cycle", "lock poisoned — keeping stale value");
}
}
}

/// Build the OS-hook callback's maps for `key` + foreground `app`. Both hook
Expand Down Expand Up @@ -183,6 +209,11 @@ impl Orchestrator {
self.config.app_settings.thumbwheel_sensitivity,
Ordering::Relaxed,
);
write_value(
&self.shared.host_switch_links,
host_switch_links(&self.config, &self.devices),
"host_switch_links",
);
}

/// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC
Expand Down Expand Up @@ -222,6 +253,12 @@ impl Orchestrator {
// Same set and routes — but keep the fresh `online` flags, or a
// device that woke this tick would read as a transition forever.
self.devices = devices;
self.sync_current_route();
write_value(
&self.shared.host_switch_links,
host_switch_links(&self.config, &self.devices),
"host_switch_links",
);
return;
}
self.devices = devices;
Expand Down Expand Up @@ -425,6 +462,31 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec<AgentDevice> {
devices
}

fn host_switch_links(config: &Config, devices: &[AgentDevice]) -> Vec<HostSwitchLink> {
config
.devices
.iter()
.filter_map(|(keyboard_key, settings)| {
let keyboard = devices
.iter()
.find(|device| device.config_key == *keyboard_key && device.online)?
.route
.clone()?;
let targets = settings
.host_switch_targets
.iter()
.filter_map(|target_key| {
devices
.iter()
.find(|device| device.config_key == *target_key)
.and_then(|device| device.route.clone())
})
.collect::<Vec<_>>();
(!targets.is_empty()).then_some(HostSwitchLink { keyboard, targets })
})
.collect()
}

/// The canonical identity of one device: what the GUI carousel orders by, what
/// the config key is derived from, and what [`reapply_targets`] matches a device
/// against across inventory ticks.
Expand Down Expand Up @@ -518,8 +580,8 @@ fn write_value<T>(lock: &RwLock<T>, value: T, name: &str) {
#[cfg(test)]
mod tests {
use super::{
AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, plan_reapply,
reapply_targets,
AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, host_switch_links,
plan_reapply, reapply_targets,
};
use openlogi_core::config::{Config, ScrollResolution};
use openlogi_core::device::Capabilities;
Expand Down Expand Up @@ -582,6 +644,45 @@ mod tests {
assert_eq!(configured_wheel_mode(&config, &device), (None, None));
}

#[test]
fn host_switch_links_keep_sleeping_targets_but_require_online_keyboard() {
let mut config = Config::default();
config
.devices
.entry("keyboard".into())
.or_default()
.host_switch_targets = vec!["mouse".into(), "offline".into(), "missing".into()];
let devices = [
dev("keyboard", 1, true),
dev("mouse", 2, true),
dev("offline", 3, false),
];

let links = host_switch_links(&config, &devices);

assert_eq!(links.len(), 1);
assert_eq!(
links[0].keyboard,
DeviceRoute::Bolt {
receiver_uid: "AA00".into(),
slot: 1,
}
);
assert_eq!(
links[0].targets,
vec![
DeviceRoute::Bolt {
receiver_uid: "AA00".into(),
slot: 2,
},
DeviceRoute::Bolt {
receiver_uid: "AA00".into(),
slot: 3,
}
]
);
}

#[test]
fn reapply_targets_new_arrivals_and_transitions() {
// First sighting of an online device → re-apply.
Expand All @@ -602,6 +703,37 @@ mod tests {
assert!(reapply_targets(&[dev("a", 1, true)], &[dev("a", 1, false)], false).is_empty());
}

#[test]
fn capture_target_tracks_online_state_without_resetting_dpi_cycle() {
let mut orch = Orchestrator::new(Config::default());
orch.devices = vec![dev("mouse", 1, true)];
orch.rebuild();
{
let Ok(mut dpi) = orch.shared.dpi_cycle.write() else {
panic!("DPI cycle lock should not be poisoned");
};
dpi.index = 3;
}

orch.devices[0].online = false;
orch.sync_current_route();
{
let Ok(dpi) = orch.shared.dpi_cycle.read() else {
panic!("DPI cycle lock should not be poisoned");
};
assert_eq!(dpi.target, None);
assert_eq!(dpi.index, 3);
}

orch.devices[0].online = true;
orch.sync_current_route();
let Ok(dpi) = orch.shared.dpi_cycle.read() else {
panic!("DPI cycle lock should not be poisoned");
};
assert_eq!(dpi.target, orch.devices[0].route);
assert_eq!(dpi.index, 3);
}

#[test]
fn reapply_targets_disambiguates_same_model_duplicates() {
// Two devices can share a model key but are distinct physical units at
Expand Down
86 changes: 86 additions & 0 deletions crates/openlogi-agent-core/src/watchers/host_switch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Keep configured keyboard → pointing-device host-switch links armed.

use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;

use openlogi_hid::{DeviceRoute, run_host_switch_session};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, warn};

/// One resolved link. Config keys are converted to live routes by the
/// orchestrator so the transport watcher never needs to understand inventory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostSwitchLink {
/// Keyboard whose host switch keys initiate the transition.
pub keyboard: DeviceRoute,
/// Pointing devices that follow the keyboard.
pub targets: Vec<DeviceRoute>,
}

/// Shared resolved links, refreshed with config and inventory.
pub type HostSwitchLinks = Arc<RwLock<Vec<HostSwitchLink>>>;

/// Spawn the host switch session manager.
pub fn spawn(links: HostSwitchLinks) {
thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
warn!(%error, "host switch watcher: could not build tokio runtime");
return;
}
};
runtime.block_on(manage(links));
});
}

async fn manage(links: HostSwitchLinks) {
let mut current = Vec::new();
let mut stops = Vec::new();
let (done_tx, mut done_rx) = mpsc::unbounded_channel();
let mut ticker = tokio::time::interval(Duration::from_secs(1));

loop {
tokio::select! {
_ = ticker.tick() => {
let wanted = links.read().map_or_else(|_| Vec::new(), |guard| guard.clone());
if wanted == current {
continue;
}
stop_all(&mut stops);
current.clone_from(&wanted);
for link in wanted {
let (stop_tx, stop_rx) = oneshot::channel();
stops.push(stop_tx);
let done = done_tx.clone();
tokio::spawn(async move {
let keyboard = link.keyboard.clone();
if let Err(error) =
run_host_switch_session(link.keyboard, link.targets, stop_rx).await
{
debug!(%error, route = %keyboard, "host switch session ended");
}
let _ = done.send(());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale completions cancel replacements

When a link change stops existing sessions and starts replacements, the stopped tasks later send indistinguishable () completions; the receive branch then stops the replacement sessions and clears their state, repeatedly leaving host keys unmonitored between re-arm attempts.

Knowledge Base Used: openlogi-agent-core

Fix in Codex Fix in Claude Code

});
}
}
Some(()) = done_rx.recv() => {
// A host switch intentionally disconnects the keyboard. Clear
// the local snapshot so the next tick attempts to arm it again;
// this also recovers transient setup/read failures.
stop_all(&mut stops);
current.clear();
}
}
}
}

fn stop_all(stops: &mut Vec<oneshot::Sender<()>>) {
for stop in stops.drain(..) {
let _ = stop.send(());
}
}
1 change: 1 addition & 0 deletions crates/openlogi-agent-core/src/watchers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
pub mod accessibility;
pub mod foreground_app;
pub mod gesture;
pub mod host_switch;
pub mod inventory;
pub mod pairing;
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ async fn run(config: Config) {
shared.thumbwheel_sensitivity.clone(),
shared.receiver_access.clone(),
);
watchers::host_switch::spawn(shared.host_switch_links.clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Receiver access bypasses arbitration

When a configured Bolt or Unifying receiver is already used by gesture capture or pairing, this watcher opens another channel without participating in ReceiverAccess, causing HID++ responses or unsolicited events to reach the wrong channel and breaking host switching, capture, or pairing.

Knowledge Base Used:

Fix in Codex Fix in Claude Code


let mut inventory_rx = watchers::inventory::spawn(Duration::from_secs(2));
let mut app_rx = watchers::foreground_app::spawn(Duration::from_secs(1));
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ mod tests {
thumbwheel_sensitivity: Arc::new(0.into()),
capture_channel: Arc::new(RwLock::new(None)),
receiver_access: ReceiverAccess::default(),
host_switch_links: Arc::new(RwLock::new(Vec::new())),
}
}

Expand Down
32 changes: 32 additions & 0 deletions crates/openlogi-core/src/config/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ pub struct DeviceConfig {
/// current resolution unmanaged and omits the field from `config.toml`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scroll_resolution: Option<ScrollResolution>,
/// Physical config keys of pointing devices that follow this keyboard's
/// host switch channel. The relationship is keyboard-initiated: pressing
/// one of this device's host keys switches every listed target first, then
/// lets the keyboard leave the current host.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_switch_targets: Vec<String>,
}

/// `skip_serializing_if` helper for plain `bool` fields whose default is
Expand Down Expand Up @@ -163,6 +169,8 @@ struct RawDeviceConfig {
invert_scroll: bool,
#[serde(default)]
scroll_resolution: Option<ScrollResolution>,
#[serde(default)]
host_switch_targets: Vec<String>,
}

impl From<RawDeviceConfig> for DeviceConfig {
Expand Down Expand Up @@ -207,6 +215,30 @@ impl From<RawDeviceConfig> for DeviceConfig {
smartshift: raw.smartshift,
invert_scroll: raw.invert_scroll,
scroll_resolution: raw.scroll_resolution,
host_switch_targets: raw.host_switch_targets,
}
}
}

#[cfg(test)]
mod tests {
use super::DeviceConfig;

#[test]
fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
let config: DeviceConfig = toml::from_str(
r#"host_switch_targets = [
"receiver:keyboard:slot:1",
"receiver:mouse:slot:2",
]"#,
)?;

assert_eq!(
config.host_switch_targets,
["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
);
let serialized = toml::to_string(&config)?;
assert!(serialized.contains("host_switch_targets"));
Ok(())
}
}
Loading