Skip to content
Open
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
2 changes: 1 addition & 1 deletion crates/openlogi-hook/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (motion is edge-clamped) |
//!
//! # Usage
//!
Expand Down
97 changes: 94 additions & 3 deletions crates/openlogi-hook/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,38 @@
reason = "Win32 FFI uses raw pointer parameters and fixed-width message values"
)]

use std::cell::Cell;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;

use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, LPARAM, LRESULT, WPARAM};
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, LPARAM, LRESULT, POINT, WPARAM};
use windows_sys::Win32::System::Threading::{
GetCurrentThreadId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
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,
WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL,
WM_QUIT, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1,
XBUTTON2,
};

use crate::{ButtonId, EventDisposition, HookError, MouseEvent};

const WHEEL_DELTA: f32 = 120.0;

thread_local! {
/// Cursor position carried by the previous `WM_MOUSEMOVE`, differenced into
/// the relative delta [`MouseEvent::Moved`] carries — see [`motion_delta`].
///
/// Thread-local rather than a `static`: `WH_MOUSE_LL` callbacks run on the
/// thread that installed the hook, so this is single-threaded by
/// construction and needs no synchronization on the freeze-sensitive
/// callback path.
static LAST_POINT: Cell<Option<POINT>> = const { Cell::new(None) };
}

type HookCallback = Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static>;

static CALLBACK: Mutex<Option<HookCallback>> = Mutex::new(None);
Expand Down Expand Up @@ -224,6 +237,12 @@ unsafe fn hook_data(lparam: LPARAM) -> Option<MSLLHOOKSTRUCT> {

fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option<MouseEvent> {
if data.flags & LLMHF_INJECTED != 0 {
// Injected motion is not the user's, but it still moves the cursor:
// advance the baseline so the next real move reports its own travel
// rather than the jump, which could otherwise commit a phantom swipe.
if wparam as u32 == WM_MOUSEMOVE {
LAST_POINT.set(Some(data.pt));
}
return None;
}

Expand Down Expand Up @@ -268,10 +287,30 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option<MouseEvent> {
from_trackpad: false,
device: None,
}),

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 First gesture movement is dropped

When a gesture button is pressed before the hook has observed any pointer movement, motion_delta uses the following WM_MOUSEMOVE only to initialize LAST_POINT, causing that movement to be omitted from the swipe accumulator and a valid swipe to resolve as the fallback click.

Knowledge Base Used: openlogi-hook: OS-level input hooking

Fix in Codex Fix in Claude Code

WM_MOUSEMOVE => {
let (delta_x, delta_y) = motion_delta(data.pt)?;
Some(MouseEvent::Moved { delta_x, delta_y })
}
_ => None,
}
}

/// Relative motion since the previous `WM_MOUSEMOVE`, or `None` for the first
/// move seen (no baseline) and for a report that didn't move the cursor.
///
/// # Limitation
/// `WH_MOUSE_LL` carries the cursor *position*, which the OS clamps to the
/// desktop bounds, so a swipe that runs into an edge stops producing deltas
/// even though the device keeps reporting counts. A gesture started near an
/// edge can therefore fail to reach the swipe threshold. Reading the device's
/// own counts needs raw input (`WM_INPUT`), a separate message pump.
fn motion_delta(pt: POINT) -> Option<(i32, i32)> {
let previous = LAST_POINT.replace(Some(pt))?;
let delta_x = pt.x - previous.x;
let delta_y = pt.y - previous.y;
(delta_x != 0 || delta_y != 0).then_some((delta_x, delta_y))
}

fn high_word(value: u32) -> u16 {
(value >> 16) as u16
}
Expand Down Expand Up @@ -333,6 +372,58 @@ fn last_error(context: &str) -> HookError {
mod tests {
use super::*;

/// The accumulator downstream sums these deltas and tests a threshold, so
/// the first move of a session must not report the cursor's absolute
/// position as travel, and a report that didn't move the cursor must not
/// count at all.
#[test]
fn motion_delta_differences_successive_points() {
let at = |x, y| POINT { x, y };
// Explicit, so the test doesn't depend on running on a fresh thread
// (`--test-threads=1` shares one with every other test).
LAST_POINT.set(None);

assert!(
motion_delta(at(800, 600)).is_none(),
"the first move has no baseline to difference against"
);
assert_eq!(motion_delta(at(860, 595)), Some((60, -5)));
assert_eq!(motion_delta(at(900, 595)), Some((40, 0)));
assert!(
motion_delta(at(900, 595)).is_none(),
"a repeated point is not motion"
);
}

/// Injected motion is filtered out of the event stream, but it still moves
/// the cursor — if it didn't advance the baseline, the next real move would
/// report the injected jump as the user's own travel and could commit a
/// swipe from a single event.
#[test]
fn injected_motion_advances_the_baseline_without_reporting() {
let injected = MSLLHOOKSTRUCT {
flags: LLMHF_INJECTED,
pt: POINT { x: 100, y: 100 },
..MSLLHOOKSTRUCT::default()
};
let real = MSLLHOOKSTRUCT {
pt: POINT { x: 110, y: 100 },
..MSLLHOOKSTRUCT::default()
};

assert!(translate_event(WM_MOUSEMOVE as WPARAM, injected).is_none());
assert!(
matches!(
translate_event(WM_MOUSEMOVE as WPARAM, real),
Some(MouseEvent::Moved {
delta_x: 10,
delta_y: 0
})
),
"the delta spans the injected point, not the pre-injection one"
);
}

#[test]
fn translate_event_ignores_injected_mouse_input() {
let data = MSLLHOOKSTRUCT {
Expand Down