Skip to main content

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