use std::sync::{Arc, Mutex, PoisonError, RwLock};
use std::time::{Duration, Instant};
use hidpp::{channel::HidppChannel, device::Device, protocol::v20};
use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use crate::channel_registry::ChannelRegistry;
use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
use crate::route::{DeviceRoute, open_route_channel};
use crate::thumbwheel::{self, Thumbwheel};
use crate::write::SharedChannel;
fn frontmost_pid() -> Option<i32> {
#[cfg(target_os = "macos")]
{
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSWorkspace;
autoreleasepool(|_| {
NSWorkspace::sharedWorkspace()
.frontmostApplication()
.map(|a| a.processIdentifier())
})
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
pub type CaptureChannel = Arc<RwLock<Option<SharedChannel>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureStop {
Graceful,
Revoked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CapturedInput {
Gesture(GestureDirection),
ButtonPressed(ButtonId, #[serde(skip)] Option<i32>),
Scroll(i16),
}
#[derive(Debug, Error)]
pub enum GestureError {
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
#[error("no connected device matched the capture route")]
DeviceNotFound,
#[error("device at index {0:#04x} did not respond to HID++")]
DeviceUnreachable(u8),
#[error("HID++ protocol error: {0}")]
Hidpp(String),
#[error("HID channel disconnected")]
ChannelDisconnected,
}
const CAPTURE_HEALTH_POLL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CaptureExit {
Stopped(CaptureStop),
Disconnected,
}
async fn wait_for_capture_exit<Shutdown>(
chan: &HidppChannel,
shutdown: Shutdown,
poll_period: Duration,
) -> CaptureExit
where
Shutdown: std::future::Future<Output = CaptureStop>,
{
tokio::pin!(shutdown);
loop {
tokio::select! {
stop = &mut shutdown => {
return CaptureExit::Stopped(stop);
}
() = tokio::time::sleep(poll_period) => {
if !chan.is_connected() {
return CaptureExit::Disconnected;
}
}
}
}
}
#[derive(Default)]
struct CaptureAccum {
swipe: SwipeAccumulator,
dpi_down: bool,
back_down: bool,
forward_down: bool,
last_back: Option<Instant>,
last_forward: Option<Instant>,
}
const BACK_FORWARD_DEBOUNCE: Duration = Duration::from_millis(150);
pub async fn run_capture_session(
route: DeviceRoute,
capture_thumbwheel: bool,
divert_gesture_button: bool,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<()>,
channel_slot: CaptureChannel,
) -> Result<(), GestureError> {
let chan = open_route_channel(&route)
.await?
.ok_or(GestureError::DeviceNotFound)?;
let shared = SharedChannel::new(chan, route.clone());
run_capture_session_on(
route,
shared,
capture_thumbwheel,
divert_gesture_button,
sink,
graceful_shutdown(shutdown),
channel_slot,
)
.await
}
pub async fn run_capture_session_with_stop_reason(
route: DeviceRoute,
capture_thumbwheel: bool,
divert_gesture_button: bool,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<CaptureStop>,
channel_slot: CaptureChannel,
) -> Result<(), GestureError> {
let chan = open_route_channel(&route)
.await?
.ok_or(GestureError::DeviceNotFound)?;
let shared = SharedChannel::new(chan, route.clone());
run_capture_session_on(
route,
shared,
capture_thumbwheel,
divert_gesture_button,
sink,
explicit_shutdown(shutdown),
channel_slot,
)
.await
}
pub async fn run_capture_session_with_registry(
route: DeviceRoute,
capture_thumbwheel: bool,
divert_gesture_button: bool,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<CaptureStop>,
channel_slot: CaptureChannel,
registry: &ChannelRegistry,
) -> Result<(), GestureError> {
let shared = registry
.lookup(&route)
.ok_or(GestureError::DeviceNotFound)?;
run_capture_session_on(
route,
shared,
capture_thumbwheel,
divert_gesture_button,
sink,
explicit_shutdown(shutdown),
channel_slot,
)
.await
}
async fn graceful_shutdown(shutdown: oneshot::Receiver<()>) -> CaptureStop {
let _ = shutdown.await;
CaptureStop::Graceful
}
async fn explicit_shutdown(shutdown: oneshot::Receiver<CaptureStop>) -> CaptureStop {
shutdown.await.unwrap_or(CaptureStop::Graceful)
}
async fn run_capture_session_on<Shutdown>(
route: DeviceRoute,
shared: SharedChannel,
capture_thumbwheel: bool,
divert_gesture_button: bool,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: Shutdown,
channel_slot: CaptureChannel,
) -> Result<(), GestureError>
where
Shutdown: std::future::Future<Output = CaptureStop>,
{
let chan = Arc::clone(shared.channel());
let device_index = route.device_index();
replace_capture_slot(&channel_slot, Some(shared));
let armed = arm_controls(
&chan,
device_index,
capture_thumbwheel,
divert_gesture_button,
)
.await;
let armed = match armed {
Ok(armed) => armed,
Err(error) => {
replace_capture_slot(&channel_slot, None);
return Err(error);
}
};
let accum = Arc::new(Mutex::new(CaptureAccum::default()));
let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx);
let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx);
let dpi_set = armed.dpi_cids.clone();
let back_set = armed.back_cids.clone();
let forward_set = armed.forward_cids.clone();
let listener = chan.add_msg_listener_guarded({
let accum = Arc::clone(&accum);
let sink = sink.clone();
move |raw, matched| {
if matched {
return;
}
let msg = v20::Message::from(raw);
if let Some(idx) = reprog_index
&& let Some(event) = reprog_controls::decode_event(&msg, device_index, idx)
{
let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
handle_reprog(&mut acc, event, &dpi_set, &back_set, &forward_set, &sink);
return;
}
if let Some(idx) = thumb_index
&& let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
{
if event.single_tap {
let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel, None));
}
if event.rotation != 0 {
let _ = sink.send(CapturedInput::Scroll(event.rotation));
}
}
}
});
info!(
index = device_index,
gesture = armed.gesture_diverted,
dpi_buttons = armed.dpi_cids.len(),
back_buttons = armed.back_cids.len(),
forward_buttons = armed.forward_cids.len(),
thumbwheel = armed.thumb.is_some(),
"control capture active"
);
let exit = wait_for_capture_exit(&chan, shutdown, CAPTURE_HEALTH_POLL).await;
drop(listener);
replace_capture_slot(&channel_slot, None);
match exit {
CaptureExit::Stopped(CaptureStop::Graceful) => {
armed.disarm().await;
debug!(index = device_index, "control capture stopped");
Ok(())
}
CaptureExit::Stopped(CaptureStop::Revoked) => {
debug!(
index = device_index,
"control capture abandoned after reconnect"
);
Ok(())
}
CaptureExit::Disconnected => {
debug!(index = device_index, "control capture channel disconnected");
Err(GestureError::ChannelDisconnected)
}
}
}
fn replace_capture_slot(slot: &CaptureChannel, value: Option<SharedChannel>) {
*slot.write().unwrap_or_else(PoisonError::into_inner) = value;
}
#[cfg_attr(
not(test),
allow(dead_code, reason = "used by gesture session unit tests")
)]
async fn teardown_capture<Listener, Clear, Disarm, DisarmFuture>(
clear: Clear,
listener: Listener,
stop: CaptureStop,
disarm: Disarm,
) where
Clear: FnOnce(),
Disarm: FnOnce() -> DisarmFuture,
DisarmFuture: std::future::Future<Output = ()>,
{
clear();
drop(listener);
if stop == CaptureStop::Graceful {
disarm().await;
}
}
struct ArmedControls {
reprog: Option<(ReprogControlsV4, u8)>,
gesture_diverted: bool,
dpi_cids: Vec<u16>,
back_cids: Vec<u16>,
forward_cids: Vec<u16>,
thumb: Option<(Thumbwheel, u8)>,
}
impl ArmedControls {
async fn disarm(&self) {
if let Some((rc, _)) = self.reprog.as_ref() {
if self.gesture_diverted {
let r = rc
.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false)
.await;
restore(r, "gesture button");
}
for &cid in &self.dpi_cids {
restore(rc.set_cid_reporting(cid, false, false).await, "DPI button");
}
for &cid in &self.back_cids {
restore(rc.set_cid_reporting(cid, false, false).await, "Back button");
}
for &cid in &self.forward_cids {
restore(
rc.set_cid_reporting(cid, false, false).await,
"Forward button",
);
}
}
if let Some((tw, _)) = self.thumb.as_ref() {
restore(tw.set_reporting(false, false).await, "thumb wheel");
}
}
}
async fn arm_controls(
chan: &Arc<HidppChannel>,
slot: u8,
capture_thumbwheel: bool,
divert_gesture_button: bool,
) -> Result<ArmedControls, GestureError> {
let device = Device::new(Arc::clone(chan), slot)
.await
.map_err(|_| GestureError::DeviceUnreachable(slot))?;
let mut reprog: Option<(ReprogControlsV4, u8)> = None;
let mut gesture_diverted = false;
let mut dpi_cids: Vec<u16> = Vec::new();
let mut back_cids: Vec<u16> = Vec::new();
let mut forward_cids: Vec<u16> = Vec::new();
if let Some(info) = device
.root()
.get_feature(reprog_controls::FEATURE_ID)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
{
let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index);
let controls = enumerate_controls(&rc).await?;
if divert_gesture_button
&& controls
.iter()
.any(|c| c.cid == reprog_controls::GESTURE_BUTTON_CID && c.supports_raw_xy())
{
rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, true, true)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
gesture_diverted = true;
}
let mut diverted_cids: Vec<u16> = Vec::new();
dpi_cids = divert_candidate_cids(
&rc,
&controls,
&reprog_controls::DPI_MODE_SHIFT_CIDS,
&mut diverted_cids,
gesture_diverted,
)
.await?;
back_cids = divert_candidate_cids(
&rc,
&controls,
&reprog_controls::BACK_CIDS,
&mut diverted_cids,
gesture_diverted,
)
.await?;
forward_cids = divert_candidate_cids(
&rc,
&controls,
&reprog_controls::FORWARD_CIDS,
&mut diverted_cids,
gesture_diverted,
)
.await?;
reprog = Some((rc, info.index));
}
let mut thumb: Option<(Thumbwheel, u8)> = None;
if capture_thumbwheel
&& let Some(info) = device
.root()
.get_feature(thumbwheel::FEATURE_ID)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
{
let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
let supports_single_tap = match tw.get_info().await {
Ok(twinfo) => twinfo.supports_single_tap,
Err(e) => {
warn!(error = ?e, "thumb wheel getInfo failed");
false
}
};
if !supports_single_tap {
debug!("thumb wheel reports no single tap — click not capturable");
}
match tw.set_reporting(true, false).await {
Ok(()) => thumb = Some((tw, info.index)),
Err(e) => {
warn!(error = ?e, "thumb wheel set_reporting failed — skipping click capture");
}
}
}
if !gesture_diverted
&& dpi_cids.is_empty()
&& back_cids.is_empty()
&& forward_cids.is_empty()
&& thumb.is_none()
{
debug!(slot, "no capturable controls — idle session");
}
Ok(ArmedControls {
reprog,
gesture_diverted,
dpi_cids,
back_cids,
forward_cids,
thumb,
})
}
async fn divert_candidate_cids(
rc: &ReprogControlsV4,
controls: &[reprog_controls::CtrlIdInfo],
candidates: &[u16],
diverted: &mut Vec<u16>,
gesture_diverted: bool,
) -> Result<Vec<u16>, GestureError> {
let mut group = Vec::new();
for &cid in candidates {
if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
if let Err(e) = rc.set_cid_reporting(cid, true, false).await {
if gesture_diverted {
let _ = rc
.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false)
.await;
}
for &d in diverted.iter() {
let _ = rc.set_cid_reporting(d, false, false).await;
}
let _ = rc.set_cid_reporting(cid, false, false).await;
return Err(GestureError::Hidpp(format!("{e:?}")));
}
group.push(cid);
diverted.push(cid);
}
}
Ok(group)
}
pub(crate) fn restore<E: std::fmt::Display>(result: Result<(), E>, what: &str) {
if let Err(e) = result {
warn!(error = %e, control = what, "failed to restore control mapping on shutdown");
}
}
pub(crate) async fn enumerate_controls(
rc: &ReprogControlsV4,
) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
let count = rc
.get_count()
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
let mut controls = Vec::with_capacity(usize::from(count));
for index in 0..count {
controls.push(
rc.get_ctrl_id_info(index)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?,
);
}
Ok(controls)
}
fn handle_reprog(
acc: &mut CaptureAccum,
event: RawControlEvent,
dpi_cids: &[u16],
back_cids: &[u16],
forward_cids: &[u16],
sink: &mpsc::UnboundedSender<CapturedInput>,
) {
match event {
RawControlEvent::DivertedButtons(cids) => {
let gesture_held = cids.contains(&reprog_controls::GESTURE_BUTTON_CID);
if gesture_held && !acc.swipe.is_holding() {
acc.swipe.begin();
} else if !gesture_held && acc.swipe.is_holding() {
if acc.swipe.end() {
debug!("gesture click");
let _ = sink.send(CapturedInput::Gesture(GestureDirection::Click));
}
}
let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
if dpi_down && !acc.dpi_down {
let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None));
}
acc.dpi_down = dpi_down;
let back_down = back_cids.iter().any(|cid| cids.contains(cid));
if back_down && !acc.back_down {
let now = Instant::now();
let elapsed = acc.last_back.map_or(BACK_FORWARD_DEBOUNCE, |t| now - t);
if elapsed >= BACK_FORWARD_DEBOUNCE {
acc.last_back = Some(now);
let _ = sink.send(CapturedInput::ButtonPressed(
ButtonId::Back,
frontmost_pid(),
));
} else {
debug!(
elapsed_ms = elapsed.as_millis(),
"Back debounced — too soon after last dispatch"
);
}
}
acc.back_down = back_down;
let forward_down = forward_cids.iter().any(|cid| cids.contains(cid));
if forward_down && !acc.forward_down {
let now = Instant::now();
let elapsed = acc.last_forward.map_or(BACK_FORWARD_DEBOUNCE, |t| now - t);
if elapsed >= BACK_FORWARD_DEBOUNCE {
acc.last_forward = Some(now);
let _ = sink.send(CapturedInput::ButtonPressed(
ButtonId::Forward,
frontmost_pid(),
));
} else {
debug!(
elapsed_ms = elapsed.as_millis(),
"Forward debounced — too soon after last dispatch"
);
}
}
acc.forward_down = forward_down;
}
RawControlEvent::RawXy { dx, dy } => {
if let Some(direction) = acc.swipe.accumulate(i32::from(dx), i32::from(dy)) {
debug!(?direction, "gesture committed");
let _ = sink.send(CapturedInput::Gesture(direction));
}
}
}
}
#[cfg(test)]
mod tests;