ib_hook/windows/shell.rs
1/*!
2Monitor window operations: creating, activating, title redrawing, monitor changing...
3
4Installs a hook procedure that receives notifications useful to Windows shell applications.
5
6See:
7- [ShellProc callback function](https://learn.microsoft.com/en-us/windows/win32/winmsg/shellproc#parameters)
8- [RegisterShellHookWindow function (winuser.h)](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registershellhookwindow#remarks)
9- [Shell Events (a.k.a. Shell Hooks) - zhuman/ShellReplacement](https://github.com/zhuman/ShellReplacement/blob/master/wiki/ShellEvents.md)
10
11## Examples
12```no_run
13use ib_hook::windows::shell::{ShellHook, ShellHookMessage};
14{
15 let hook = ShellHook::new(Box::new(|msg: ShellHookMessage| {
16 println!("{msg:?}");
17 false
18 }))
19 .unwrap();
20
21 // Perform window operations to see received events...
22 std::thread::sleep(std::time::Duration::from_secs(30));
23}
24// Shell hook unregistered
25```
26
27## Disclaimer
28Ref:
29- https://github.com/YousefAliUK/FerroDock/blob/b405832a64c763f073b37d9a42a0690d0c15416b/src/events.rs
30- https://gist.github.com/Aetopia/347e7329158aa2c69df97bdf0b761d6f
31*/
32use std::sync::OnceLock;
33
34use windows::Win32::{
35 Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
36 UI::{
37 Controls::WC_STATIC,
38 WindowsAndMessaging::{
39 CreateWindowExW, DefWindowProcW, DeregisterShellHookWindow, DestroyWindow,
40 DispatchMessageW, GWLP_USERDATA, GWLP_WNDPROC, GetMessageW, GetWindowLongPtrW,
41 HWND_MESSAGE, MSG, RegisterShellHookWindow, RegisterWindowMessageW, SHELLHOOKINFO,
42 SetWindowLongPtrW, TranslateMessage, WINDOW_EX_STYLE, WINDOW_STYLE,
43 },
44 },
45};
46use windows::core::w;
47
48use crate::log::*;
49
50pub use windows::Win32::UI::WindowsAndMessaging::{
51 HSHELL_ACCESSIBILITYSTATE, HSHELL_ACTIVATESHELLWINDOW, HSHELL_APPCOMMAND, HSHELL_ENDTASK,
52 HSHELL_GETMINRECT, HSHELL_HIGHBIT, HSHELL_LANGUAGE, HSHELL_MONITORCHANGED, HSHELL_REDRAW,
53 HSHELL_SYSMENU, HSHELL_TASKMAN, HSHELL_WINDOWACTIVATED, HSHELL_WINDOWCREATED,
54 HSHELL_WINDOWDESTROYED, HSHELL_WINDOWREPLACED, HSHELL_WINDOWREPLACING,
55};
56
57// Missing shell hook constants from the windows crate
58pub const HSHELL_RUDEAPPACTIVATED: u32 = HSHELL_WINDOWACTIVATED | HSHELL_HIGHBIT;
59pub const HSHELL_FLASH: u32 = HSHELL_REDRAW | HSHELL_HIGHBIT;
60
61/// The return value should be `false` unless the message is [`ShellHookMessage::AppCommand`]
62/// and the callback handles the [`WM_COMMAND`] message. In this case, the return should be `true`.
63pub type ShellHookCallback = dyn FnMut(ShellHookMessage) -> bool + Send + 'static;
64
65/// Shell hook message variants.
66///
67/// These correspond to the shell hook messages sent via [`RegisterShellHookWindow`].
68///
69/// Ref:
70/// - [ShellProc callback function - Win32 apps | Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/winmsg/shellproc#parameters)
71/// - [RegisterShellHookWindow function (winuser.h) - Win32 apps | Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registershellhookwindow#remarks)
72#[derive(Debug, Clone, Copy)]
73pub enum ShellHookMessage {
74 /// A top-level, unowned window has been created.
75 /// The window exists when the system calls this hook.
76 ///
77 /// A handle to the window being created.
78 WindowCreated(HWND),
79
80 /// A top-level, unowned window is about to be destroyed.
81 /// The window still exists when the system calls this hook.
82 ///
83 /// A handle to the top-level window being destroyed.
84 WindowDestroyed(HWND),
85
86 /// The shell should activate its main window.
87 ActivateShellWindow,
88
89 /// The activation has changed to a different top-level, unowned window.
90 ///
91 /// A handle to the activated window.
92 WindowActivated(HWND),
93
94 /// The activation has changed to a different top-level, unowned window in full-screen mode.
95 ///
96 /// A handle to the activated window.
97 ///
98 /// Ref: [c# - Does anybody know what means ShellHook message `HSHELL_RUDEAPPACTIVATED`? - Stack Overflow](https://stackoverflow.com/questions/1178020/does-anybody-know-what-means-shellhook-message-hshell-rudeappactivated)
99 RudeAppActivated(HWND),
100
101 /// A window is being minimized or maximized.
102 /// The system needs the coordinates of the minimized rectangle for the window.
103 ///
104 /// - A handle to the minimized or maximized window.
105 /// - A pointer to a RECT structure.
106 GetMinRect(HWND, RECT),
107
108 /// The user has selected the task list.
109 /// A shell application that provides a task list should return `TRUE` to prevent Windows from starting its task list.
110 ///
111 /// The param can be ignored.
112 TaskMan(LPARAM),
113
114 /// Keyboard language was changed or a new keyboard layout was loaded.
115 ///
116 /// - A handle to the window.
117 /// - A handle to a keyboard layout.
118 ///
119 /// May require DLL hook.
120 Language(HWND),
121
122 /// May require DLL hook.
123 SysMenu(LPARAM),
124
125 /// A handle to the window that should be forced to exit.
126 EndTask(HWND),
127
128 /// The accessibility state has changed.
129 ///
130 /// Indicates which accessibility feature has changed state.
131 /// This value is one of the following: `ACCESS_FILTERKEYS`, `ACCESS_MOUSEKEYS`, or `ACCESS_STICKYKEYS`.
132 ///
133 /// May require DLL hook.
134 AccessibilityState(LPARAM),
135
136 /// The title of a window in the task bar has been redrawn.
137 ///
138 /// A handle to the window that needs to be redrawn.
139 Redraw(HWND),
140
141 /// A handle to the window that needs to be flashed.
142 Flash(HWND),
143
144 /// The user completed an input event (for example, pressed an application command button on the mouse or an application command key on the keyboard),
145 /// and the application did not handle the [`WM_APPCOMMAND`] message generated by that input.
146 ///
147 /// - The [`APPCOMMAND`] which has been unhandled by the application or other hooks.
148 AppCommand(LPARAM),
149
150 /// A top-level window is being replaced.
151 /// The window exists when the system calls this hook.
152 ///
153 /// A handle to the window being replaced.
154 WindowReplaced(HWND),
155
156 /// A handle to the window replacing the top-level window.
157 WindowReplacing(HWND),
158
159 /// A handle to the window that moved to a different monitor.
160 MonitorChanged(HWND),
161
162 /// Unknown shell hook message.
163 Unknown(WPARAM, LPARAM),
164}
165
166impl From<(WPARAM, LPARAM)> for ShellHookMessage {
167 fn from(value: (WPARAM, LPARAM)) -> Self {
168 let (wparam, lparam) = value;
169 match wparam.0 as u32 {
170 HSHELL_WINDOWCREATED => Self::WindowCreated(HWND(lparam.0 as _)),
171 HSHELL_WINDOWDESTROYED => Self::WindowDestroyed(HWND(lparam.0 as _)),
172 HSHELL_ACTIVATESHELLWINDOW => Self::ActivateShellWindow,
173 HSHELL_WINDOWACTIVATED => Self::WindowActivated(HWND(lparam.0 as _)),
174 HSHELL_RUDEAPPACTIVATED => Self::RudeAppActivated(HWND(lparam.0 as _)),
175 HSHELL_GETMINRECT => {
176 let info = unsafe { &*(lparam.0 as *const SHELLHOOKINFO) };
177 Self::GetMinRect(info.hwnd, info.rc)
178 }
179 HSHELL_TASKMAN => Self::TaskMan(lparam),
180 HSHELL_LANGUAGE => Self::Language(HWND(lparam.0 as _)),
181 HSHELL_SYSMENU => Self::SysMenu(lparam),
182 HSHELL_ENDTASK => Self::EndTask(HWND(lparam.0 as _)),
183 HSHELL_ACCESSIBILITYSTATE => Self::AccessibilityState(lparam),
184 HSHELL_REDRAW => Self::Redraw(HWND(lparam.0 as _)),
185 HSHELL_FLASH => Self::Flash(HWND(lparam.0 as _)),
186 HSHELL_APPCOMMAND => Self::AppCommand(lparam),
187 HSHELL_WINDOWREPLACED => Self::WindowReplaced(HWND(lparam.0 as _)),
188 HSHELL_WINDOWREPLACING => Self::WindowReplacing(HWND(lparam.0 as _)),
189 HSHELL_MONITORCHANGED => Self::MonitorChanged(HWND(lparam.0 as _)),
190 _ => Self::Unknown(wparam, lparam),
191 }
192 }
193}
194
195pub struct ShellHook {
196 _thread: Option<std::thread::JoinHandle<()>>,
197 hwnd: OnceLock<usize>,
198}
199
200impl ShellHook {
201 pub fn new(callback: Box<ShellHookCallback>) -> windows::core::Result<Self> {
202 Self::with_on_hooked(callback, |_| ())
203 }
204
205 pub fn with_on_hooked(
206 mut callback: Box<ShellHookCallback>,
207 on_hooked: impl FnOnce(&mut ShellHookCallback) + Send + 'static,
208 ) -> windows::core::Result<Self> {
209 let hwnd = OnceLock::new();
210
211 // Start the message loop in a separate thread
212 let _thread = std::thread::spawn({
213 let hwnd_store = hwnd.clone();
214 move || {
215 /*
216 let shell_msg = unsafe { RegisterWindowMessageW(w!("SHELLHOOK")) };
217 SHELL_HOOK_MSG.set(shell_msg).ok();
218 */
219
220 /*
221 let class_name = w!("ib_hook::shell");
222
223 let wc = WNDCLASSW {
224 lpfnWndProc: Some(window_proc),
225 hInstance: Module::current().0.into(),
226 lpszClassName: class_name,
227 ..Default::default()
228 };
229
230 // Only `RegisterClass` once
231 CLASS_REGISTER.call_once(|| {
232 if unsafe { RegisterClassW(&wc) } == 0 {
233 error!("Failed to register window class");
234 }
235 });
236 */
237 let class_name = WC_STATIC;
238
239 let hwnd = unsafe {
240 CreateWindowExW(
241 WINDOW_EX_STYLE::default(),
242 class_name,
243 w!("ShellHookWindow"),
244 WINDOW_STYLE::default(),
245 0,
246 0,
247 0,
248 0,
249 // Message-only window
250 Some(HWND_MESSAGE),
251 None,
252 // Some(wc.hInstance),
253 None,
254 None,
255 )
256 }
257 .unwrap();
258
259 if hwnd.0.is_null() {
260 error!("Failed to create shell hook window");
261 return;
262 }
263
264 // Store hwnd as usize in OnceLock
265 let _ = hwnd_store.set(hwnd.0 as usize);
266
267 // Set callback in window user data
268 let callback_ref = callback.as_mut() as *mut _;
269 let callback_ptr = Box::into_raw(Box::new(callback)) as isize;
270 unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, callback_ptr) };
271
272 unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, window_proc as *const () as isize) };
273
274 if !unsafe { RegisterShellHookWindow(hwnd) }.as_bool() {
275 error!("Failed to register shell hook window");
276 return;
277 }
278
279 debug!("Shell hook window created: {:?}", hwnd);
280
281 // SAFETY: Callback will only be called in DispatchMessageW()
282 on_hooked(unsafe { &mut *callback_ref });
283
284 // Run the message loop
285 let mut msg = MSG::default();
286 while unsafe { GetMessageW(&mut msg, None, 0, 0).as_bool() } {
287 let _ = unsafe { TranslateMessage(&msg) };
288 let _ = unsafe { DispatchMessageW(&msg) };
289 }
290 }
291 });
292
293 Ok(ShellHook {
294 _thread: Some(_thread),
295 hwnd,
296 })
297 }
298
299 pub fn hwnd(&self) -> Option<HWND> {
300 self.hwnd.get().map(|&h| HWND(h as _))
301 }
302}
303
304impl Drop for ShellHook {
305 fn drop(&mut self) {
306 if let Some(hwnd) = self.hwnd() {
307 // Unregister from shell hook messages
308 _ = unsafe { DeregisterShellHookWindow(hwnd) };
309
310 // Clean up the callback
311 unsafe {
312 let callback_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA);
313 if callback_ptr != 0 {
314 _ = Box::from_raw(callback_ptr as *mut Box<ShellHookCallback>);
315 }
316 }
317
318 // Destroy the window
319 _ = unsafe { DestroyWindow(hwnd) };
320 }
321 }
322}
323
324static SHELL_HOOK_MSG: OnceLock<u32> = OnceLock::new();
325
326fn shell_hook_msg() -> u32 {
327 *SHELL_HOOK_MSG.get_or_init(|| unsafe { RegisterWindowMessageW(w!("SHELLHOOK")) })
328}
329
330/*
331static CLASS_REGISTER: Once = Once::new();
332*/
333
334/// The return value should be zero unless the value of nCode is [`HSHELL_APPCOMMAND`]
335/// and the shell procedure handles the [`WM_COMMAND`] message. In this case, the return should be nonzero.
336unsafe extern "system" fn window_proc(
337 hwnd: HWND,
338 msg: u32,
339 wparam: WPARAM,
340 lparam: LPARAM,
341) -> LRESULT {
342 if msg == shell_hook_msg() {
343 let callback = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) };
344 if callback != 0 {
345 let callback = unsafe { &mut *(callback as *mut Box<ShellHookCallback>) };
346 let r = callback(ShellHookMessage::from((wparam, lparam)));
347 return LRESULT(r as _);
348 }
349 }
350
351 unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::{thread, time::Duration};
358
359 #[test]
360 fn shell_hook() {
361 println!("Testing ShellHook - perform various window operations to see events");
362
363 let hook = ShellHook::new(Box::new(|msg: ShellHookMessage| {
364 println!("{msg:?}");
365 false
366 }))
367 .expect("Failed to create shell hook");
368
369 println!("Shell hook registered with hwnd={:?}", hook.hwnd());
370 println!("Test will complete in 1 seconds...\n");
371
372 // Keep the hook alive for a bit to receive events
373 thread::sleep(Duration::from_secs(1));
374
375 // Drop hook explicitly to demonstrate cleanup
376 drop(hook);
377 println!("\nShell hook destroyed.");
378 }
379
380 #[ignore]
381 #[test]
382 fn shell_hook_manual() {
383 println!("Testing ShellHook - perform various window operations to see events");
384
385 let hook = ShellHook::new(Box::new(|msg: ShellHookMessage| {
386 println!("{msg:?}");
387 false
388 }))
389 .expect("Failed to create shell hook");
390
391 println!("Shell hook registered with hwnd={:?}", hook.hwnd());
392 println!("Perform window operations (open/close apps, alt+tab, etc.) to see events...");
393 println!("Test will complete in 30 seconds...\n");
394
395 // Keep the hook alive for a bit to receive events
396 thread::sleep(Duration::from_secs(30));
397
398 // Drop hook explicitly to demonstrate cleanup
399 drop(hook);
400 println!("\nShell hook destroyed.");
401 }
402}