From 307043b5cd5898923028b06e0d8f165d821e40e6 Mon Sep 17 00:00:00 2001 From: Stanley5249 Date: Mon, 27 Jul 2026 22:13:00 +0800 Subject: [PATCH] fix(hook): read Windows pointer motion from raw input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WH_MOUSE_LL carries the absolute cursor point, which the OS clamps at the desktop edge, so no motion reached the gesture accumulator on Windows at all. Raw input reports the device's own relative counts, matching macOS MOUSE_EVENT_DELTA_X and Linux REL_X. A message-only sink window registers for WM_INPUT with RIDEV_INPUTSINK (the agent never holds focus). The hook thread drains the whole queued backlog per wakeup via GetRawInputBuffer rather than one record per message: it also services the low-level hook, and reading singly put a 1 kHz mouse's report stream in front of every click. Records are released by calling DefWindowProcW directly, so cleanup does not depend on the Static control's window procedure. Buttons and motion arrive on two independent paths, and a WH_MOUSE_LL callback runs ahead of WM_INPUT records already queued, so a press discards the pending backlog — otherwise a flick made just before a gesture-button press would be summed into the fresh hold. --- crates/openlogi-hook/Cargo.toml | 9 +- crates/openlogi-hook/src/lib.rs | 2 +- crates/openlogi-hook/src/windows.rs | 81 +++- crates/openlogi-hook/src/windows/raw_input.rs | 346 ++++++++++++++++++ 4 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 crates/openlogi-hook/src/windows/raw_input.rs diff --git a/crates/openlogi-hook/Cargo.toml b/crates/openlogi-hook/Cargo.toml index ec082243..31e9d205 100644 --- a/crates/openlogi-hook/Cargo.toml +++ b/crates/openlogi-hook/Cargo.toml @@ -34,13 +34,16 @@ objc2 = { workspace = true } objc2-foundation = { workspace = true, features = ["NSString"] } objc2-app-kit = { workspace = true, features = ["NSWorkspace", "NSRunningApplication"] } -# WH_MOUSE_LL low-level mouse hook + foreground-process path (GetForegroundWindow -# / QueryFullProcessImageNameW). +# WH_MOUSE_LL low-level mouse hook (buttons + wheel), raw input (unclamped +# pointer deltas) + foreground-process path (GetForegroundWindow / +# QueryFullProcessImageNameW). [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { workspace = true, features = [ + # HID_USAGE_PAGE_GENERIC / HID_USAGE_GENERIC_MOUSE, the raw-input usage pair. + "Win32_Devices_HumanInterfaceDevice", "Win32_Foundation", "Win32_System_Threading", - "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Input", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index 25f92269..07957d17 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -4,7 +4,7 @@ //! |----------|---------------| //! | macOS | `CGEventTap` (same primitive used by Logi Options+) | //! | Linux | `evdev` grab + `uinput` re-injection | -//! | Windows | `WH_MOUSE_LL` low-level mouse hook | +//! | Windows | `WH_MOUSE_LL` low-level mouse hook + raw input (motion) | //! //! # Usage //! diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs index 3c718a76..15f4030b 100644 --- a/crates/openlogi-hook/src/windows.rs +++ b/crates/openlogi-hook/src/windows.rs @@ -1,4 +1,5 @@ -//! Windows `WH_MOUSE_LL` implementation of the OS-level mouse hook. +//! Windows implementation of the OS-level mouse hook: `WH_MOUSE_LL` for buttons +//! and wheel (it alone can suppress an event), raw input for pointer motion. #![allow( clippy::borrow_as_ptr, clippy::cast_possible_truncation, @@ -7,6 +8,8 @@ reason = "Win32 FFI uses raw pointer parameters and fixed-width message values" )] +mod raw_input; + use std::sync::{Arc, Mutex, mpsc}; use std::thread; @@ -14,12 +17,14 @@ use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, LPARAM, LRESULT, use windows_sys::Win32::System::Threading::{ GetCurrentThreadId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }; +use windows_sys::Win32::UI::Input::HRAWINPUT; use windows_sys::Win32::UI::WindowsAndMessaging::{ - CallNextHookEx, DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowThreadProcessId, - HC_ACTION, LLMHF_INJECTED, MSG, MSLLHOOKSTRUCT, PM_NOREMOVE, PeekMessageW, PostThreadMessageW, - SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, WH_MOUSE_LL, WM_LBUTTONDOWN, - WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEWHEEL, WM_QUIT, - WM_RBUTTONDOWN, WM_RBUTTONUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, XBUTTON2, + CallNextHookEx, DefWindowProcW, DispatchMessageW, GetForegroundWindow, GetMessageW, + GetWindowThreadProcessId, HC_ACTION, LLMHF_INJECTED, MSG, MSLLHOOKSTRUCT, PM_NOREMOVE, + PeekMessageW, PostThreadMessageW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, + WH_MOUSE_LL, WM_INPUT, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, + WM_MOUSEHWHEEL, WM_MOUSEWHEEL, WM_QUIT, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_USER, WM_XBUTTONDOWN, + WM_XBUTTONUP, XBUTTON1, XBUTTON2, }; use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; @@ -132,9 +137,24 @@ fn hook_thread(callback: HookCallback, ready: mpsc::Sender Some(hwnd), + Err(e) => { + tracing::warn!(error = %e, "raw mouse input unavailable; gestures will not commit"); + None + } + }; + let _ = ready.send(Ok(thread_id)); message_loop(); + // The raw-input registration is process-wide, so it has to be dropped + // explicitly — this thread owns the sink window, so it must happen here. + if let Some(hwnd) = sink { + raw_input::uninstall(hwnd); + } // SAFETY: `hook` is the live handle just returned by SetWindowsHookExW, // unhooked exactly once here as the thread exits. unsafe { @@ -145,6 +165,9 @@ fn hook_thread(callback: HookCallback, ready: mpsc::Sender EventDisposition { + CALLBACK + .lock() + .ok() + .and_then(|slot| slot.clone()) + .map_or(EventDisposition::PassThrough, |cb| cb(event)) +} + fn clear_callback() { if let Ok(mut slot) = CALLBACK.lock() { *slot = None; @@ -195,11 +252,13 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) return call_next(code, wparam, lparam); }; - let callback = CALLBACK.lock().ok().and_then(|slot| slot.clone()); - let disposition = callback - .as_ref() - .map_or(EventDisposition::PassThrough, |cb| cb(event)); - match disposition { + // A press begins a gesture hold downstream, and motion that predates it is + // still queued as unread `WM_INPUT` — see `raw_input::invalidate`. + if matches!(event, MouseEvent::Button { pressed: true, .. }) { + raw_input::invalidate(); + } + + match dispatch(event) { EventDisposition::PassThrough => call_next(code, wparam, lparam), EventDisposition::Suppress => 1, } diff --git a/crates/openlogi-hook/src/windows/raw_input.rs b/crates/openlogi-hook/src/windows/raw_input.rs new file mode 100644 index 00000000..19441100 --- /dev/null +++ b/crates/openlogi-hook/src/windows/raw_input.rs @@ -0,0 +1,346 @@ +//! Pointer motion for the Windows hook, read from raw input (`WM_INPUT`). +//! +//! Motion can't come from the hook itself: `WH_MOUSE_LL` carries the absolute +//! cursor point, which the OS clamps at the desktop edge, so differencing it +//! reports nothing for the rest of a swipe that runs into one. Raw input +//! reports the device's own relative counts, like macOS `MOUSE_EVENT_DELTA_X` +//! and Linux `REL_X`. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use windows_sys::Win32::Devices::HumanInterfaceDevice::{ + HID_USAGE_GENERIC_MOUSE, HID_USAGE_PAGE_GENERIC, +}; +use windows_sys::Win32::Foundation::HWND; +use windows_sys::Win32::UI::Input::{ + GetRawInputBuffer, GetRawInputData, HRAWINPUT, MOUSE_MOVE_ABSOLUTE, RAWINPUT, RAWINPUTDEVICE, + RAWINPUTHEADER, RAWMOUSE, RID_INPUT, RIDEV_INPUTSINK, RIDEV_REMOVE, RIM_TYPEMOUSE, + RegisterRawInputDevices, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{CreateWindowExW, DestroyWindow, HWND_MESSAGE}; +use windows_sys::core::w; + +use super::last_error; +use crate::{HookError, MouseEvent}; + +/// Set when the low-level hook sees a button press, cleared by the next drain, +/// which discards its motion — see [`invalidate`]. +/// +/// Written and read on the hook thread alone (`WH_MOUSE_LL` callbacks run on the +/// installing thread), so `Relaxed` is enough; it is a `static` only because the +/// hook procedure is a bare `extern "system"` function. +static DISCARD_QUEUED_MOTION: AtomicBool = AtomicBool::new(false); + +/// Batch buffer size, in `u64`s — `Vec` because `GetRawInputBuffer` +/// requires QWORD alignment, which a `Vec` does not give. +/// +/// 16 KiB holds ~340 mouse records, a third of a second of backlog at a 1 kHz +/// polling rate. If it ever does overflow the call fails and the records stay +/// queued, to be read one at a time by the per-message path — slower, not +/// broken. +const BATCH_QWORDS: usize = 2048; + +/// Offset of a record's payload union from its start, in QWORDs — the step from +/// a record pointer to its `RAWMOUSE`. +const DATA_QWORDS: usize = { + let offset = std::mem::offset_of!(RAWINPUT, data); + assert!( + offset.is_multiple_of(size_of::()), + "RAWINPUT payload must be QWORD-aligned for the batch walk" + ); + offset / size_of::() +}; + +/// QWORDs a record's header occupies — the minimum that must remain in the +/// batch buffer before one can be read. +const HEADER_QWORDS: usize = size_of::().div_ceil(size_of::()); + +/// QWORDs from a record's start past the end of its `RAWMOUSE` payload. +const MOUSE_END_QWORDS: usize = DATA_QWORDS + size_of::().div_ceil(size_of::()); + +/// Subscribe to raw mouse input, delivered as `WM_INPUT` to this thread's +/// queue, and return the sink window to pass back to [`uninstall`]. +/// +/// `RIDEV_INPUTSINK` — raw input while unfocused, and the agent never has focus +/// — needs a target window, so this creates a message-only one of the predefined +/// `Static` class. Its window procedure is never relied on: the message loop +/// releases each `WM_INPUT` record by calling `DefWindowProcW` itself. +pub(super) fn install() -> Result { + // SAFETY: the class name is a live NUL-terminated UTF-16 literal naming a + // predefined system class; HWND_MESSAGE as parent creates a message-only + // window, for which null instance/menu/param and zero style and geometry + // are valid. Returns null on failure, checked below. + let hwnd = unsafe { + CreateWindowExW( + 0, + w!("Static"), + std::ptr::null(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }; + if hwnd.is_null() { + return Err(last_error("CreateWindowExW")); + } + + let device = RAWINPUTDEVICE { + usUsagePage: HID_USAGE_PAGE_GENERIC, + usUsage: HID_USAGE_GENERIC_MOUSE, + dwFlags: RIDEV_INPUTSINK, + hwndTarget: hwnd, + }; + // SAFETY: `device` is a live, fully initialized RAWINPUTDEVICE and the + // count and struct size describe it exactly; `hwndTarget` is the window + // just created on this thread, which outlives the registration. + if unsafe { RegisterRawInputDevices(&device, 1, size_of::() as u32) } == 0 { + // SAFETY: `hwnd` is the live window just created on this thread. + unsafe { DestroyWindow(hwnd) }; + return Err(last_error("RegisterRawInputDevices")); + } + Ok(hwnd) +} + +/// Drop the raw-input subscription and the sink window. +/// +/// The registration is process-wide and outlives `hwnd`, so destroying the +/// window is not enough: without the explicit `RIDEV_REMOVE` a later reinstall +/// would re-register the same usage page against a new window with the stale +/// entry still in place. +/// +/// Must run on the thread that called [`install`] — `DestroyWindow` only works +/// on the window's owning thread. +pub(super) fn uninstall(hwnd: HWND) { + let device = RAWINPUTDEVICE { + usUsagePage: HID_USAGE_PAGE_GENERIC, + usUsage: HID_USAGE_GENERIC_MOUSE, + dwFlags: RIDEV_REMOVE, + // RIDEV_REMOVE requires a null target. + hwndTarget: std::ptr::null_mut(), + }; + // SAFETY: `device` is a live, fully initialized RAWINPUTDEVICE and the + // count and struct size describe it exactly. + if unsafe { RegisterRawInputDevices(&device, 1, size_of::() as u32) } == 0 { + tracing::warn!(error = %last_error("RegisterRawInputDevices(RIDEV_REMOVE)"), "could not unregister raw mouse input"); + } + // SAFETY: `hwnd` was returned by `install` on this thread and is destroyed + // exactly once, here. + unsafe { DestroyWindow(hwnd) }; +} + +/// Discard the motion already queued when a button transition arrives. +/// +/// Buttons and motion reach the hook thread on two independent paths: a +/// `WH_MOUSE_LL` callback runs as soon as the thread waits for a message, ahead +/// of `WM_INPUT` records already sitting in the queue. Without this, a flick +/// followed a millisecond later by a gesture-button press would have its +/// *pre-press* travel summed into the fresh hold, committing a swipe the user +/// never made during it. Dropping the queued backlog costs at most the motion +/// between the press and the next drain — far inside the hold's swipe window. +pub(super) fn invalidate() { + DISCARD_QUEUED_MOTION.store(true, Ordering::Relaxed); +} + +/// Drain this thread's raw-input queue and return the total pointer motion in +/// it, starting from the record `current` announces. +/// +/// The hook thread services `WH_MOUSE_LL` between `GetMessageW` calls, and +/// Windows drops hook events the thread doesn't answer within +/// `LowLevelHooksTimeout`. Reading one record per message put a 1 kHz mouse's +/// whole report stream in front of every click; `GetRawInputBuffer` takes the +/// backlog in one syscall instead. +/// +/// Summing the batch is what [`crate::MouseEvent::Moved`]'s consumer does with +/// the deltas anyway — the accumulator adds them up and tests a threshold — so +/// batching only defers a commit to the end of the drain, which the thread +/// reaches as soon as the queue empties. +/// +/// # Safety +/// `current` must be the `lParam` of a `WM_INPUT` message just retrieved from +/// this thread's queue, i.e. a live raw-input handle the OS has not yet +/// released. +pub(super) unsafe fn drain_motion(current: HRAWINPUT, batch: &mut Vec) -> Option { + // Taken before the drain, not after: the hook procedure that sets it runs on + // this thread, which is busy here, so nothing can arrive mid-drain. + let discard = DISCARD_QUEUED_MOTION.swap(false, Ordering::Relaxed); + + // SAFETY: `current` is a live raw-input handle by this function's contract. + let (mut dx, mut dy) = unsafe { read_one(current) }.unwrap_or((0, 0)); + + if batch.is_empty() { + batch.resize(BATCH_QWORDS, 0); + } + loop { + let mut size = (batch.len() * size_of::()) as u32; + // SAFETY: `batch` is a live allocation of `size` bytes, QWORD-aligned + // because it is a `Vec`. Returns 0 when the queue is empty and + // u32::MAX on failure; both stop the drain below. + let count = unsafe { + GetRawInputBuffer( + batch.as_mut_ptr().cast(), + &mut size, + size_of::() as u32, + ) + }; + if count == 0 || count == u32::MAX { + break; + } + + // Walked in QWORDs rather than bytes so each record pointer inherits + // the buffer's alignment instead of being cast up from a `*const u8`. + let mut at = 0usize; + for _ in 0..count { + // Every read below is bounds-checked against what is left of the + // buffer rather than trusted to `dwSize`: this is `unsafe`, so a + // record whose size desynchronises the walk must stop it, not turn + // into a read past the allocation. + let Some(record) = batch.get(at..) else { + break; + }; + if record.len() < HEADER_QWORDS { + break; + } + // SAFETY: the OS wrote `count` records into `batch` and at least a + // header's worth of buffer remains. Read by value — records are + // plain-old-data — from a pointer the slice keeps QWORD-aligned. + let header = unsafe { std::ptr::read(record.as_ptr().cast::()) }; + // Records are packed QWORD-aligned, not end to end. + let stride = (header.dwSize as usize).div_ceil(size_of::()); + if stride == 0 { + break; + } + if header.dwType == RIM_TYPEMOUSE && record.len() >= MOUSE_END_QWORDS { + // SAFETY: a mouse record's payload is the `mouse` arm of the + // union at `RAWINPUT`'s `data` offset, and the whole payload + // lies inside the buffer per the length check above. + let mouse = + unsafe { std::ptr::read(record.as_ptr().add(DATA_QWORDS).cast::()) }; + if let Some((x, y)) = motion(mouse) { + dx = dx.saturating_add(x); + dy = dy.saturating_add(y); + } + } + at += stride; + } + } + + // The queue still had to be drained — the records are the OS's to reclaim — + // but a button press just invalidated everything in it. + if discard { + return None; + } + + (dx != 0 || dy != 0).then_some(MouseEvent::Moved { + delta_x: dx, + delta_y: dy, + }) +} + +/// Read the single record behind a `WM_INPUT` handle. +/// +/// # Safety +/// `handle` must be a live raw-input handle — see [`drain_motion`]. +unsafe fn read_one(handle: HRAWINPUT) -> Option<(i32, i32)> { + let mut raw = RAWINPUT::default(); + let mut size = size_of::() as u32; + // SAFETY: `handle` is a live raw-input handle by this function's contract; + // `raw` is a live, owned RAWINPUT and `size` its byte length, so the call + // writes at most that much. Returns u32::MAX on failure, checked below. + let read = unsafe { + GetRawInputData( + handle, + RID_INPUT, + std::ptr::from_mut(&mut raw).cast(), + &mut size, + size_of::() as u32, + ) + }; + if read == u32::MAX || raw.header.dwType != RIM_TYPEMOUSE { + return None; + } + // SAFETY: the header says this record is a mouse, so the union holds + // `mouse`. + motion(unsafe { raw.data.mouse }) +} + +/// Pointer motion carried by a mouse record, or `None` if it isn't relative +/// movement. Raw input has no injected-input flag, so another process's +/// `SendInput` motion feeds a gesture hold here; our own injector never moves +/// the pointer. +fn motion(mouse: RAWMOUSE) -> Option<(i32, i32)> { + // Absolute-mode devices (tablets, RDP, some VMs) report a screen position + // in these fields rather than a delta — the very clamping this path exists + // to avoid, so they contribute no gesture motion. + if mouse.usFlags & MOUSE_MOVE_ABSOLUTE != 0 { + return None; + } + // Button-only records repeat the last position with a zero delta. + (mouse.lLastX != 0 || mouse.lLastY != 0).then_some((mouse.lLastX, mouse.lLastY)) +} + +#[cfg(test)] +mod tests { + use windows_sys::Win32::UI::Input::{MOUSE_MOVE_RELATIVE, MOUSE_STATE}; + + use super::*; + + /// Raw input is the only motion source, and it feeds the swipe accumulator: + /// relative counts pass through untouched, while absolute-mode records (a + /// screen position, already edge-clamped) and the zero-delta records that + /// accompany button presses must not. + #[test] + fn motion_reports_relative_deltas_only() { + let mouse = |flags: MOUSE_STATE, x, y| RAWMOUSE { + usFlags: flags, + lLastX: x, + lLastY: y, + ..RAWMOUSE::default() + }; + + assert_eq!(motion(mouse(MOUSE_MOVE_RELATIVE, 60, -5)), Some((60, -5))); + assert!( + motion(mouse(MOUSE_MOVE_ABSOLUTE, 60, -5)).is_none(), + "absolute records carry a clamped position, not a delta" + ); + assert!( + motion(mouse(MOUSE_MOVE_RELATIVE, 0, 0)).is_none(), + "button-only records repeat a zero delta" + ); + } + + /// The batch walk steps `dwSize` rounded up to the next QWORD, not + /// `dwSize` itself, and reaches the payload via [`DATA_QWORDS`]. Getting + /// either wrong desynchronises the walk from the second record on, which + /// would read neighbouring records as garbage deltas. + #[test] + fn batch_walk_matches_record_layout() { + let record = size_of::() + size_of::(); + let stride = record.next_multiple_of(size_of::()); + assert!(stride >= record, "a record is never truncated by alignment"); + assert!( + stride.is_multiple_of(size_of::()), + "the next record starts on a QWORD boundary" + ); + assert!( + DATA_QWORDS * size_of::() >= size_of::(), + "the payload follows the header, never overlaps it" + ); + // The bounds guards must admit a well-formed record, or the walk would + // silently skip every mouse payload instead of summing it. + assert!(HEADER_QWORDS * size_of::() >= size_of::()); + assert!( + MOUSE_END_QWORDS * size_of::() >= record, + "the payload bound covers a whole mouse record" + ); + assert!( + stride / size_of::() >= HEADER_QWORDS, + "a record's stride always leaves room for the next header check" + ); + } +}