#![allow(
clippy::borrow_as_ptr,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::needless_pass_by_value,
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, POINT, WPARAM};
use windows_sys::Win32::System::Threading::{
GetCurrentThreadId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
GetAsyncKeyState, VIRTUAL_KEY, VK_CONTROL, VK_ESCAPE, VK_F1, VK_LWIN, VK_MENU, VK_RWIN,
VK_SHIFT,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowThreadProcessId,
HC_ACTION, KBDLLHOOKSTRUCT, LLKHF_INJECTED, LLMHF_INJECTED, MSG, MSLLHOOKSTRUCT, PM_NOREMOVE,
PeekMessageW, PostThreadMessageW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
WH_KEYBOARD_LL, WH_MOUSE_LL, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP,
WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_QUIT,
WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN,
WM_XBUTTONUP, XBUTTON1, XBUTTON2,
};
use crate::{ButtonId, EventDisposition, HookError, HookEvent, KeyEvent, KeyModifiers, MouseEvent};
const WHEEL_DELTA: f32 = 120.0;
thread_local! {
static LAST_POINT: Cell<Option<POINT>> = const { Cell::new(None) };
}
type HookCallback = Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync + 'static>;
static CALLBACK: Mutex<Option<HookCallback>> = Mutex::new(None);
pub(crate) struct HookInner {
thread_id: u32,
join: Option<thread::JoinHandle<()>>,
}
pub(crate) fn start(
cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
let callback: HookCallback = Arc::new(cb);
let (ready_tx, ready_rx) = mpsc::channel();
let join = thread::Builder::new()
.name("openlogi-windows-hook".into())
.spawn(move || hook_thread(callback, ready_tx))
.map_err(|e| HookError::WindowsHook(format!("could not spawn hook thread: {e}")))?;
match ready_rx
.recv()
.map_err(|e| HookError::WindowsHook(format!("hook thread exited before setup: {e}")))?
{
Ok(thread_id) => Ok(HookInner {
thread_id,
join: Some(join),
}),
Err(e) => {
let _ = join.join();
Err(e)
}
}
}
pub(crate) fn stop(mut inner: HookInner) {
let posted = unsafe { PostThreadMessageW(inner.thread_id, WM_QUIT, 0, 0) };
if posted == 0 {
let err = unsafe { GetLastError() };
tracing::warn!(error = err, "could not post WM_QUIT to Windows hook thread");
}
if let Some(join) = inner.join.take()
&& let Err(e) = join.join()
{
tracing::warn!(?e, "Windows hook thread panicked while stopping");
}
}
fn hook_thread(callback: HookCallback, ready: mpsc::Sender<Result<u32, HookError>>) {
match CALLBACK.lock() {
Ok(mut slot) if slot.is_none() => {
*slot = Some(callback);
}
Ok(_) => {
let _ = ready.send(Err(HookError::WindowsHook(
"another Windows input hook is already installed".into(),
)));
return;
}
Err(e) => {
let _ = ready.send(Err(HookError::WindowsHook(format!(
"callback lock poisoned: {e}"
))));
return;
}
}
let thread_id = unsafe { GetCurrentThreadId() };
let mut bootstrap_msg = MSG::default();
unsafe {
PeekMessageW(
&mut bootstrap_msg,
std::ptr::null_mut(),
WM_USER,
WM_USER,
PM_NOREMOVE,
);
}
let mouse_hook = unsafe {
SetWindowsHookExW(
WH_MOUSE_LL,
Some(mouse_proc),
std::ptr::null_mut::<core::ffi::c_void>(),
0,
)
};
if mouse_hook.is_null() {
clear_callback();
let _ = ready.send(Err(last_error("SetWindowsHookExW(WH_MOUSE_LL)")));
return;
}
let keyboard_hook = unsafe {
SetWindowsHookExW(
WH_KEYBOARD_LL,
Some(keyboard_proc),
std::ptr::null_mut::<core::ffi::c_void>(),
0,
)
};
if keyboard_hook.is_null() {
let error = last_error("SetWindowsHookExW(WH_KEYBOARD_LL)");
unsafe {
UnhookWindowsHookEx(mouse_hook);
}
clear_callback();
let _ = ready.send(Err(error));
return;
}
let _ = ready.send(Ok(thread_id));
message_loop();
unsafe {
UnhookWindowsHookEx(keyboard_hook);
UnhookWindowsHookEx(mouse_hook);
}
clear_callback();
}
fn message_loop() {
let mut msg = MSG::default();
loop {
let result = unsafe { GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) };
if result <= 0 {
break;
}
unsafe { TranslateMessage(&msg) };
unsafe { DispatchMessageW(&msg) };
}
}
fn clear_callback() {
if let Ok(mut slot) = CALLBACK.lock() {
*slot = None;
}
}
fn call_next(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
unsafe { CallNextHookEx(std::ptr::null_mut(), code, wparam, lparam) }
}
unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code != HC_ACTION as i32 {
return call_next(code, wparam, lparam);
}
let Some(data) = (unsafe { hook_data(lparam) }) else {
return call_next(code, wparam, lparam);
};
let Some(event) = translate_event(wparam, data) else {
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(HookEvent::Mouse(event))
});
match disposition {
EventDisposition::PassThrough => call_next(code, wparam, lparam),
EventDisposition::Suppress => 1,
}
}
unsafe fn hook_data(lparam: LPARAM) -> Option<MSLLHOOKSTRUCT> {
if lparam == 0 {
return None;
}
Some(unsafe { *(lparam as *const MSLLHOOKSTRUCT) })
}
fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option<MouseEvent> {
let previous = LAST_POINT.replace(Some(data.pt));
if data.flags & LLMHF_INJECTED != 0 {
return None;
}
let pressed = match wparam as u32 {
WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN | WM_XBUTTONDOWN => Some(true),
WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP | WM_XBUTTONUP => Some(false),
_ => None,
};
if let Some(pressed) = pressed {
let id = match wparam as u32 {
WM_LBUTTONDOWN | WM_LBUTTONUP => ButtonId::LeftClick,
WM_RBUTTONDOWN | WM_RBUTTONUP => ButtonId::RightClick,
WM_MBUTTONDOWN | WM_MBUTTONUP => ButtonId::MiddleClick,
WM_XBUTTONDOWN | WM_XBUTTONUP => match high_word(data.mouseData) {
XBUTTON1 => ButtonId::Back,
XBUTTON2 => ButtonId::Forward,
_ => return None,
},
_ => return None,
};
return Some(MouseEvent::Button {
id,
pressed,
device: None,
});
}
match wparam as u32 {
WM_MOUSEWHEEL => Some(MouseEvent::Scroll {
delta_x: 0.0,
delta_y: f32::from(signed_high_word(data.mouseData)) / WHEEL_DELTA,
from_trackpad: false,
device: None,
}),
WM_MOUSEHWHEEL => Some(MouseEvent::Scroll {
delta_x: f32::from(signed_high_word(data.mouseData)) / WHEEL_DELTA,
delta_y: 0.0,
from_trackpad: false,
device: None,
}),
WM_MOUSEMOVE => {
let (delta_x, delta_y) = motion_delta(previous?, data.pt)?;
Some(MouseEvent::Moved { delta_x, delta_y })
}
_ => None,
}
}
fn motion_delta(previous: POINT, pt: POINT) -> Option<(i32, i32)> {
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))
}
unsafe extern "system" fn keyboard_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code != HC_ACTION as i32 {
return call_next(code, wparam, lparam);
}
let Some(data) = (unsafe { key_hook_data(lparam) }) else {
return call_next(code, wparam, lparam);
};
let Some(event) = translate_key(wparam, data, current_modifiers()) else {
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(HookEvent::Key(event))
});
match disposition {
EventDisposition::PassThrough => call_next(code, wparam, lparam),
EventDisposition::Suppress => 1,
}
}
unsafe fn key_hook_data(lparam: LPARAM) -> Option<KBDLLHOOKSTRUCT> {
if lparam == 0 {
return None;
}
Some(unsafe { *(lparam as *const KBDLLHOOKSTRUCT) })
}
fn translate_key(
wparam: WPARAM,
data: KBDLLHOOKSTRUCT,
modifiers: KeyModifiers,
) -> Option<KeyEvent> {
if data.flags & LLKHF_INJECTED != 0 {
return None;
}
let pressed = match wparam as u32 {
WM_KEYDOWN | WM_SYSKEYDOWN => true,
WM_KEYUP | WM_SYSKEYUP => false,
_ => return None,
};
Some(KeyEvent {
keycode: mac_keycode(data.vkCode)?,
pressed,
modifiers,
})
}
const FKEY_MAC_KEYCODES: [u16; 19] = [
0x7A, 0x78, 0x63, 0x76, 0x60, 0x61, 0x62, 0x64, 0x65, 0x6D, 0x67, 0x6F, 0x69, 0x6B, 0x71, 0x6A,
0x40, 0x4F, 0x50,
];
fn mac_keycode(vk: u32) -> Option<u16> {
let vk = u16::try_from(vk).ok()?;
if vk == VK_ESCAPE {
return Some(0x35);
}
FKEY_MAC_KEYCODES
.get(usize::from(vk.checked_sub(VK_F1)?))
.copied()
}
fn current_modifiers() -> KeyModifiers {
KeyModifiers {
shift: key_held(VK_SHIFT),
control: key_held(VK_CONTROL),
option: key_held(VK_MENU),
command: key_held(VK_LWIN) || key_held(VK_RWIN),
}
}
fn key_held(vk: VIRTUAL_KEY) -> bool {
(unsafe { GetAsyncKeyState(i32::from(vk)) }) < 0
}
fn high_word(value: u32) -> u16 {
(value >> 16) as u16
}
fn signed_high_word(value: u32) -> i16 {
high_word(value) as i16
}
pub(crate) fn frontmost_process_path() -> Option<String> {
let hwnd = unsafe { GetForegroundWindow() };
if hwnd.is_null() {
return None;
}
let mut pid = 0;
unsafe {
GetWindowThreadProcessId(hwnd, &mut pid);
}
if pid == 0 {
return None;
}
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return None;
}
let mut buf = vec![0u16; 32_768];
let mut len = buf.len() as u32;
let ok = unsafe { QueryFullProcessImageNameW(process, 0, buf.as_mut_ptr(), &mut len) };
unsafe {
CloseHandle(process);
}
if ok == 0 || len == 0 {
return None;
}
Some(String::from_utf16_lossy(&buf[..len as usize]).to_lowercase())
}
fn last_error(context: &str) -> HookError {
let code = unsafe { GetLastError() };
HookError::WindowsHook(format!("{context} failed with GetLastError={code}"))
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use super::*;
#[test]
fn button_down_seeds_the_baseline_for_the_first_move() {
let at = |x, y| MSLLHOOKSTRUCT {
pt: POINT { x, y },
..MSLLHOOKSTRUCT::default()
};
LAST_POINT.set(None);
translate_event(WM_LBUTTONDOWN as WPARAM, at(500, 400));
assert!(matches!(
translate_event(WM_MOUSEMOVE as WPARAM, at(560, 395)),
Some(MouseEvent::Moved {
delta_x: 60,
delta_y: -5
})
));
assert!(
translate_event(WM_MOUSEMOVE as WPARAM, at(560, 395)).is_none(),
"a repeated point is not motion"
);
}
#[test]
fn translate_event_ignores_injected_mouse_input() {
let data = MSLLHOOKSTRUCT {
flags: LLMHF_INJECTED,
..MSLLHOOKSTRUCT::default()
};
assert!(translate_event(WM_LBUTTONDOWN as WPARAM, data).is_none());
assert!(translate_event(WM_MOUSEMOVE as WPARAM, data).is_none());
}
fn key(vk: u16) -> KBDLLHOOKSTRUCT {
KBDLLHOOKSTRUCT {
vkCode: u32::from(vk),
..KBDLLHOOKSTRUCT::default()
}
}
#[test]
fn emitted_keycodes_match_the_key_trigger_vocabulary() {
use openlogi_core::config::KeyTrigger;
let esc: KeyTrigger = "esc".parse().expect("parse key trigger");
assert_eq!(mac_keycode(u32::from(VK_ESCAPE)), Some(esc.keycode));
for n in 1..=19u16 {
let trigger: KeyTrigger = format!("f{n}").parse().expect("parse key trigger");
assert_eq!(
mac_keycode(u32::from(VK_F1 + n - 1)),
Some(trigger.keycode),
"f{n}"
);
}
}
#[test]
fn translate_key_maps_press_and_release() {
let mods = KeyModifiers::default();
let down = translate_key(WM_KEYDOWN as WPARAM, key(VK_F1 + 17), mods);
assert!(matches!(
down,
Some(KeyEvent {
keycode: 0x4F,
pressed: true,
..
})
));
let up = translate_key(WM_KEYUP as WPARAM, key(VK_F1 + 17), mods);
assert!(matches!(up, Some(KeyEvent { pressed: false, .. })));
}
#[test]
fn translate_key_handles_syskey_messages() {
let mods = KeyModifiers {
option: true,
..KeyModifiers::default()
};
let down = translate_key(WM_SYSKEYDOWN as WPARAM, key(VK_F1), mods);
assert!(matches!(
down,
Some(KeyEvent {
keycode: 0x7A,
pressed: true,
..
})
));
let up = translate_key(WM_SYSKEYUP as WPARAM, key(VK_F1), mods);
assert!(matches!(up, Some(KeyEvent { pressed: false, .. })));
}
#[test]
fn translate_key_ignores_injected_keyboard_input() {
let data = KBDLLHOOKSTRUCT {
vkCode: u32::from(VK_F1),
flags: LLKHF_INJECTED,
..KBDLLHOOKSTRUCT::default()
};
assert!(translate_key(WM_KEYDOWN as WPARAM, data, KeyModifiers::default()).is_none());
}
#[test]
fn translate_key_passes_unmapped_keys_through() {
let mods = KeyModifiers::default();
assert!(translate_key(WM_KEYDOWN as WPARAM, key(0x41), mods).is_none());
assert!(translate_key(WM_KEYDOWN as WPARAM, key(VK_F1 + 19), mods).is_none());
}
#[test]
fn wheel_forward_scrolls_up_like_other_platforms() {
let forward = MSLLHOOKSTRUCT {
mouseData: 120u32 << 16,
..MSLLHOOKSTRUCT::default()
};
let Some(MouseEvent::Scroll {
delta_x, delta_y, ..
}) = translate_event(WM_MOUSEWHEEL as WPARAM, forward)
else {
panic!("expected a scroll event");
};
assert!(delta_x.abs() < f32::EPSILON);
assert!(
delta_y > 0.0,
"wheel-forward should scroll up, got {delta_y}"
);
}
}