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::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}