use std::collections::BTreeSet;
#[cfg(not(target_os = "windows"))]
use std::error::Error;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex as StdMutex, PoisonError};
#[cfg(not(target_os = "windows"))]
use async_hid::{AsyncHidRead, AsyncHidWrite, DeviceReader, DeviceWriter};
use async_hid::{DeviceInfo, HidBackend};
use futures_lite::{Stream, StreamExt as _};
#[cfg(not(target_os = "windows"))]
use hidpp::async_trait;
use hidpp::channel::{ChannelObserver, HidppChannel, RawHidChannel, RequestSwId, SwIdPolicy};
use hidpp::nibble::U4;
#[cfg(not(target_os = "windows"))]
use tokio::sync::Mutex;
use tracing::{debug, warn};
use crate::LOGITECH_VENDOR_ID;
use openlogi_device::backend::{BackendError, HotplugEvent, NodeId, NodeInfo};
use openlogi_device::host_lock;
use openlogi_device::write::matches_litra;
use openlogi_device::{DeviceIoGate, DeviceIoSignal, device_io_channel};
fn backend_error(error: async_hid::HidError) -> BackendError {
match error {
async_hid::HidError::Disconnected | async_hid::HidError::NotConnected => {
BackendError::Disconnected
}
other => BackendError::Backend(other.to_string()),
}
}
#[cfg(not(target_os = "windows"))]
fn open_error(error: async_hid::HidError) -> BackendError {
match backend_error(error) {
#[cfg(target_os = "macos")]
BackendError::Backend(message) => {
let hint = if crate::permissions::has_access() {
"Input Monitoring is granted to this process — another app may \
hold the device exclusively, or macOS is serving a stale \
permission session (log out and back in)"
} else {
"Input Monitoring is NOT granted to this process; grant it to \
OpenLogi Agent under System Settings → Privacy & Security → \
Input Monitoring"
};
BackendError::Backend(format!("{message}: {hint}"))
}
other => other,
}
}
fn node_id(info: &DeviceInfo) -> NodeId {
NodeId::from(format!("{:?}", info.id))
}
fn node_info(info: &DeviceInfo) -> NodeInfo {
{
NodeInfo {
id: node_id(info),
vendor_id: info.vendor_id,
product_id: info.product_id,
usage_page: info.usage_page,
usage_id: info.usage_id,
name: info.name.clone(),
manufacturer: info.manufacturer.clone(),
serial_number: info.serial_number.clone(),
}
}
}
static SW_ID_LEASES: StdMutex<BTreeSet<(String, u8)>> = StdMutex::new(BTreeSet::new());
struct SwIdLease {
id: RequestSwId,
key: (String, u8),
_lock: Option<host_lock::HostLock>,
}
impl SwIdLease {
fn id(&self) -> RequestSwId {
self.id
}
}
impl Drop for SwIdLease {
fn drop(&mut self) {
SW_ID_LEASES
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&self.key);
}
}
static SW_ID_LOCK_FALLBACK_REPORTED: AtomicBool = AtomicBool::new(false);
mod native;
pub(crate) use native::{native_backend, recording_backend};
#[cfg(any(target_os = "windows", test))]
mod windows;
#[cfg(target_os = "windows")]
mod windows_hid;
#[cfg(target_os = "windows")]
use windows::WindowsHidppChannel;
#[cfg(test)]
use windows::normalize_collection_path;
const HIDPP_LONG_COLLECTIONS: [(u16, u16, bool); 3] = [
(0xff00, 0x0002, false),
(0xff43, 0x0202, true),
(0xff43, 0x0602, false),
];
fn is_hidpp_long_collection(usage_page: u16, usage_id: u16) -> bool {
HIDPP_LONG_COLLECTIONS
.iter()
.any(|&(page, usage, _)| (page, usage) == (usage_page, usage_id))
}
#[cfg_attr(
target_os = "windows",
expect(clippy::allow_attributes, reason = "see above"),
allow(
dead_code,
reason = "long-only up-conversion is the non-Windows AsyncHidChannel path"
)
)]
fn is_long_only_collection(usage_page: u16, usage_id: u16) -> bool {
HIDPP_LONG_COLLECTIONS
.iter()
.any(|&(page, usage, long_only)| long_only && (page, usage) == (usage_page, usage_id))
}
static HID_BACKEND: LazyLock<HidBackend> = LazyLock::new(HidBackend::default);
static DEVICE_IO: LazyLock<(DeviceIoSignal, DeviceIoGate)> = LazyLock::new(device_io_channel);
pub(crate) fn device_io_signal() -> DeviceIoSignal {
DEVICE_IO.0.clone()
}
pub(crate) fn device_io_gate() -> DeviceIoGate {
DEVICE_IO.1.clone()
}
pub(crate) fn watch_nodes() -> Result<impl Stream<Item = HotplugEvent> + Send + Unpin, BackendError>
{
let stream = HID_BACKEND.watch().map_err(backend_error)?;
Ok(stream.map(|event| match event {
async_hid::DeviceEvent::Connected(_) => HotplugEvent::Connected,
async_hid::DeviceEvent::Disconnected(_) => HotplugEvent::Disconnected,
}))
}
pub(crate) async fn enumerate_devices() -> Result<Vec<async_hid::Device>, BackendError> {
let all: Vec<async_hid::Device> = HID_BACKEND
.enumerate()
.await
.map_err(backend_error)?
.collect()
.await;
for d in all.iter().filter(|d| d.vendor_id == LOGITECH_VENDOR_ID) {
debug!(
name = %d.name,
pid = format_args!("{:04x}", d.product_id),
usage_page = format_args!("{:#06x}", d.usage_page),
usage_id = format_args!("{:#06x}", d.usage_id),
matched = is_hidpp_long_collection(d.usage_page, d.usage_id),
"logitech HID node"
);
}
Ok(all)
}
pub(crate) fn is_hidpp_node(device: &async_hid::Device) -> bool {
is_hidpp_candidate(
device.vendor_id,
device.product_id,
device.usage_page,
device.usage_id,
is_receiver_child_node(&device.id),
)
}
fn is_hidpp_candidate(
vendor_id: u16,
product_id: u16,
usage_page: u16,
usage_id: u16,
receiver_child: bool,
) -> bool {
vendor_id == LOGITECH_VENDOR_ID
&& is_hidpp_long_collection(usage_page, usage_id)
&& !matches_litra(vendor_id, product_id, usage_page, usage_id)
&& !receiver_child
}
#[cfg(target_os = "linux")]
fn is_receiver_child_node(id: &async_hid::DeviceId) -> bool {
use async_hid::DeviceId;
let DeviceId::DevPath(dev_path) = id else {
return false;
};
let Some(node_name) = dev_path.file_name().and_then(|n| n.to_str()) else {
return false;
};
let sysfs_link = format!("/sys/class/hidraw/{node_name}/device");
let Ok(real_path) = std::fs::canonicalize(&sysfs_link) else {
return false;
};
is_receiver_child_sysfs_path(&real_path.to_string_lossy())
}
#[cfg(any(target_os = "linux", test))]
fn is_receiver_child_sysfs_path(path: &str) -> bool {
crate::RECEIVERS.iter().any(|receiver| {
let marker = format!(":{:04X}:{:04X}.", receiver.vendor_id, receiver.product_id);
path.find(&marker)
.is_some_and(|idx| path[idx + marker.len()..].contains('/'))
})
}
#[cfg(not(target_os = "linux"))]
fn is_receiver_child_node(_id: &async_hid::DeviceId) -> bool {
false
}
fn sw_id_lock_name(node: &NodeId, id: u8) -> String {
format!("{}-sw-{id:02}", host_lock::node_lock_name(node))
}
fn try_lease_sw_id(node: &NodeId) -> Option<SwIdLease> {
let node_name = host_lock::node_lock_name(node);
let mut leases = SW_ID_LEASES.lock().unwrap_or_else(PoisonError::into_inner);
for sw_id in (1u8..=15).filter_map(|id| RequestSwId::new(U4::from_lo(id))) {
let id = sw_id.get().to_lo();
let key = (node_name.clone(), id);
if leases.contains(&key) {
continue;
}
let lock = match host_lock::try_lock(&sw_id_lock_name(node, id)) {
Ok(Some(lock)) => Some(lock),
Ok(None) => continue,
Err(error) => {
if !SW_ID_LOCK_FALLBACK_REPORTED.swap(true, Ordering::Relaxed) {
warn!(
dir = %host_lock::lock_dir().display(),
%error,
"HID++ software-id lock directory unusable — ids are unique to this process only, \
so another OpenLogi process opening the same device may cross-match replies"
);
}
None
}
};
leases.insert(key.clone());
return Some(SwIdLease {
id: sw_id,
key,
_lock: lock,
});
}
None
}
fn configure_channel_sw_ids(channel: &mut HidppChannel, node: &NodeId) -> Result<(), BackendError> {
let lease = try_lease_sw_id(node).ok_or_else(|| {
BackendError::Backend(
"all 15 HID++ software ids on this node are leased by this or other OpenLogi processes — refusing an open that would share one".into(),
)
})?;
channel.set_sw_id_policy(SwIdPolicy::Leased {
id: lease.id(),
lease: Box::new(lease),
});
Ok(())
}
pub(crate) async fn open_hidpp_channel(
dev: &async_hid::Device,
device_io: DeviceIoGate,
) -> Result<Option<Arc<HidppChannel>>, BackendError> {
open_hidpp_channel_inner(dev, device_io, None).await
}
pub(crate) async fn open_hidpp_channel_with_observer(
dev: &async_hid::Device,
device_io: DeviceIoGate,
observer: Arc<dyn ChannelObserver>,
) -> Result<Option<Arc<HidppChannel>>, BackendError> {
open_hidpp_channel_inner(dev, device_io, Some(observer)).await
}
async fn open_hidpp_channel_inner(
dev: &async_hid::Device,
device_io: DeviceIoGate,
observer: Option<Arc<dyn ChannelObserver>>,
) -> Result<Option<Arc<HidppChannel>>, BackendError> {
device_io.ensure_allowed()?;
let info: DeviceInfo = (**dev).clone();
#[cfg(target_os = "windows")]
{
let raw = WindowsHidppChannel::open(dev, info.clone(), device_io)
.await
.map_err(backend_error)?;
let channel = match hidpp_channel_from_raw(raw, observer).await {
Ok(mut c) => {
configure_channel_sw_ids(&mut c, &node_id(&info))?;
Arc::new(c)
}
Err(e) => {
debug!(name = %info.name, error = ?e, "not a HID++ channel");
return Ok(None);
}
};
Ok(Some(channel))
}
#[cfg(not(target_os = "windows"))]
{
let (reader, writer) = dev.open().await.map_err(open_error)?;
device_io.ensure_allowed()?;
let long_only = is_long_only_collection(info.usage_page, info.usage_id);
let raw = AsyncHidChannel::new(reader, writer, info.clone(), long_only, device_io);
let channel = match hidpp_channel_from_raw(raw, observer).await {
Ok(mut c) => {
configure_channel_sw_ids(&mut c, &node_id(&info))?;
Arc::new(c)
}
Err(e) => {
debug!(name = %info.name, error = ?e, "not a HID++ channel");
return Ok(None);
}
};
debug!(name = %info.name, vid = format_args!("{:04x}", info.vendor_id), "opened HID++ channel");
Ok(Some(channel))
}
}
pub(crate) async fn hidpp_channel_from_raw(
raw: impl RawHidChannel,
observer: Option<Arc<dyn ChannelObserver>>,
) -> Result<HidppChannel, hidpp::channel::ChannelError> {
match observer {
Some(observer) => HidppChannel::from_raw_channel_with_observer(raw, observer).await,
None => HidppChannel::from_raw_channel(raw).await,
}
}
#[cfg(test)]
mod sw_id_lease_tests {
use std::fs::{File, TryLockError};
use openlogi_device::backend::NodeId;
use openlogi_device::host_lock;
use super::{SW_ID_LEASES, sw_id_lock_name, try_lease_sw_id};
fn node(tag: &str) -> NodeId {
NodeId::from(format!("sw-id-lease-test-{}-{tag}", std::process::id()))
}
#[test]
fn lease_holds_the_ids_os_lock_until_dropped() {
let node = node("hold");
let lease = try_lease_sw_id(&node).expect("a fresh node has every id free");
let id = lease.id().get().to_lo();
let observer = File::open(host_lock::lock_dir().join(sw_id_lock_name(&node, id)))
.expect("leasing creates the id's lock file");
assert!(
matches!(observer.try_lock(), Err(TryLockError::WouldBlock)),
"a leased id's lock file must be held"
);
drop(lease);
assert!(
!SW_ID_LEASES
.lock()
.unwrap()
.contains(&(host_lock::node_lock_name(&node), id))
);
observer
.try_lock()
.expect("dropping the lease releases the OS lock");
}
#[test]
fn lease_skips_an_id_held_by_another_process() {
let node = node("skip");
let foreign = host_lock::try_lock(&sw_id_lock_name(&node, 1))
.unwrap()
.expect("nothing else locks this test's node");
let lease = try_lease_sw_id(&node).expect("fourteen ids remain");
assert_ne!(
lease.id().get().to_lo(),
1,
"an id locked by another process must be skipped"
);
drop(lease);
drop(foreign);
}
#[test]
fn leases_on_one_node_are_unique_until_dropped() {
let node = node("unique");
let a = try_lease_sw_id(&node).unwrap();
let b = try_lease_sw_id(&node).unwrap();
assert_ne!(a.id(), b.id());
let first = a.id();
drop(a);
let c = try_lease_sw_id(&node).unwrap();
assert_eq!(c.id(), first, "a dropped id is reusable");
drop(b);
drop(c);
}
#[test]
fn unrelated_nodes_do_not_share_one_pool() {
let left = node("left");
let right = node("right");
let all_of_left: Vec<_> = (0..15)
.map(|_| try_lease_sw_id(&left).expect("fifteen ids per node"))
.collect();
assert!(
try_lease_sw_id(&left).is_none(),
"the sixteenth channel on one node is refused"
);
let on_right = try_lease_sw_id(&right).expect("another node is unaffected");
assert_eq!(on_right.id().get().to_lo(), 1);
drop(on_right);
drop(all_of_left);
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) struct AsyncHidChannel {
reader: Mutex<DeviceReader>,
writer: Mutex<DeviceWriter>,
info: DeviceInfo,
connected: AtomicBool,
device_io: DeviceIoGate,
long_only: bool,
}
#[cfg(not(target_os = "windows"))]
impl AsyncHidChannel {
pub(crate) fn new(
reader: DeviceReader,
writer: DeviceWriter,
info: DeviceInfo,
long_only: bool,
device_io: DeviceIoGate,
) -> Self {
Self {
reader: Mutex::new(reader),
writer: Mutex::new(writer),
info,
connected: AtomicBool::new(true),
device_io,
long_only,
}
}
fn mark_disconnected(&self) {
if self.connected.swap(false, Ordering::AcqRel) {
debug!(name = %self.info.name, "HID channel disconnected");
}
}
}
#[cfg(not(target_os = "windows"))]
#[async_trait]
impl RawHidChannel for AsyncHidChannel {
fn vendor_id(&self) -> u16 {
self.info.vendor_id
}
fn product_id(&self) -> u16 {
self.info.product_id
}
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
self.device_io.ensure_allowed()?;
let mut w = self.writer.lock().await;
self.device_io.ensure_allowed()?;
match w.write_output_report(src).await {
Ok(()) => Ok(src.len()),
Err(e) => {
if matches!(e, async_hid::HidError::Disconnected) {
self.mark_disconnected();
}
Err(e.into())
}
}
}
async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
let result = {
let mut r = self.reader.lock().await;
r.read_input_report(buf).await
};
match result {
Ok(n) => Ok(n),
Err(async_hid::HidError::Disconnected) => {
self.mark_disconnected();
std::future::pending().await
}
Err(e) => Err(e.into()),
}
}
fn is_connected(&self) -> bool {
self.connected.load(Ordering::Acquire)
}
fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
Some((!self.long_only, true))
}
async fn get_report_descriptor(
&self,
_buf: &mut [u8],
) -> Result<usize, Box<dyn Error + Send + Sync>> {
Err("get_report_descriptor is not implemented; pre-filter to HID++ usage pages".into())
}
}
#[cfg(test)]
mod tests;