wintrack/
lib.rs

1#![cfg(windows)]
2#![warn(missing_docs)]
3
4//! A library for monitoring window related events on Windows.
5//!
6//! This crate allows you to set a callback that will be called for common window events.
7//! The callback receives a [`WindowEvent`], which includes the [event kind](WindowEventKind) and
8//! a [snapshot of the window's state](WindowSnapshot) at the time of the event.
9//!
10//! This library allows you to listen for the following events:
11//! - [Foreground (active) window changed](WindowEventKind::ForegroundWindowChanged)
12//! - [Window title or name changed](WindowEventKind::WindowNameChanged)
13//! - [Window became visible (unminimized / moved onscreen)](WindowEventKind::WindowBecameVisible)
14//! - [Window became hidden (minimized / moved offscreen)](WindowEventKind::WindowBecameHidden)
15//! - [New window was created](WindowEventKind::WindowCreated)
16//! - [Window was destroyed or closed](WindowEventKind::WindowDestroyed)
17//! - [Window was moved or resized](WindowEventKind::WindowMovedOrResized)
18//!
19//! The [snapshot](WindowSnapshot) contains fields including
20//! title, rect, executable, and some lower level information.
21//!
22//! # Usage
23//! First, call [`try_hook`], which spawns a thread that sets a hook & listens for events.
24//! ```no_run
25//! wintrack::try_hook().expect("no hook should be set yet");
26//! ```
27//! Then, define a callback that will be called for each event.
28//! ```no_run
29//! wintrack::set_callback(Box::new(|evt| {
30//!     // ignore events from zero-sized windows or windows with no title
31//!     if evt.snapshot.rect.size() != (0, 0) && !evt.snapshot.title.is_empty() {
32//!         dbg!(evt.snapshot);
33//!     }
34//! }));
35//! ```
36//! At the end of your program, optionally [`unhook`] the hook.
37//! ```no_run
38//! wintrack::unhook().expect("should have set hook earlier")
39//! ```
40//!
41//! For an example of using a channel to collect events or listen for events in another location of your program,
42//! see the example in the documentation for [`set_callback`].
43
44#[cfg(not(windows))]
45compile_error!("The `wintrack` crate only supports Windows.");
46
47use std::panic;
48use std::thread::JoinHandle;
49use parking_lot::Mutex;
50use windows::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_INVALID_THREAD_ID, ERROR_INVALID_WINDOW_HANDLE, ERROR_MOD_NOT_FOUND, ERROR_PROC_NOT_FOUND, HWND, LPARAM, WPARAM};
51use windows::core::{Error as WinErr, BOOL};
52use windows::Win32::System::Threading::GetCurrentThreadId;
53use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};
54use windows::Win32::UI::WindowsAndMessaging::{GetMessageW, PostThreadMessageW, CHILDID_SELF, EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY, EVENT_OBJECT_HIDE, EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_NAMECHANGE, EVENT_OBJECT_SHOW, EVENT_SYSTEM_FOREGROUND, MSG, OBJECT_IDENTIFIER, OBJID_WINDOW, WINEVENT_OUTOFCONTEXT, WM_QUIT};
55pub use window_info::WinThreadId;
56
57mod window_info;
58pub use window_info::*;
59
60/// A window event.
61///
62/// Represents an event related to a window, like becoming foreground or title change,
63/// along with a [snapshot](WindowSnapshot) of the window when the event occurred.
64///
65/// Most likely, you'll get a window event from the callback set by [`set_callback`].
66///
67/// # Examples
68/// ```no_run
69/// # use wintrack::{WindowEvent, WindowEventKind, WindowSnapshot};
70/// wintrack::set_callback(Box::new(|event: WindowEvent| {
71///     // every event has a snapshot of the window's current state
72///     let snapshot: WindowSnapshot = event.snapshot;
73///     assert_eq!(snapshot.title, "Firefox");
74///     assert_eq!(snapshot.class_name, "MozillaWindowClass");
75///
76///     // ... and the kind of event that caused it
77///     if event.kind == WindowEventKind::ForegroundWindowChanged {
78///         assert!(snapshot.is_foreground);
79///     }
80/// }));
81/// ```
82#[derive(Clone, Debug, Eq, PartialEq, Hash)]
83pub struct WindowEvent {
84    /// The specific type of event that occurred.
85    pub kind: WindowEventKind,
86    /// A snapshot of the window's properties when the event occurred. 
87    pub snapshot: WindowSnapshot,
88}
89
90/// The kind of the event that occurred for a window.
91///
92/// Each corresponds to a [Windows event constant](https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants). 
93#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
94pub enum WindowEventKind {
95    /// The foreground (active) window changed. ([`EVENT_SYSTEM_FOREGROUND`])
96    ForegroundWindowChanged,
97    /// The window title or name changed. ([`EVENT_OBJECT_NAMECHANGE`])
98    WindowNameChanged,
99    /// The window became visible (unminimized / moved onscreen). ([`EVENT_OBJECT_SHOW`])
100    WindowBecameVisible,
101    /// The window became hidden (minimized / moved offscreen). ([`EVENT_OBJECT_HIDE`])
102    WindowBecameHidden,
103    /// A new window was created. ([`EVENT_OBJECT_CREATE`])
104    WindowCreated,
105    /// A window was destroyed or closed. ([`EVENT_OBJECT_DESTROY`])
106    WindowDestroyed,
107    /// A window was moved or resized. ([`EVENT_OBJECT_LOCATIONCHANGE`])
108    WindowMovedOrResized,
109}
110
111impl WindowEventKind {
112    pub(crate) const ALL: [Self; 7] = [
113        Self::ForegroundWindowChanged,
114        Self::WindowNameChanged,
115        Self::WindowBecameVisible,
116        Self::WindowBecameHidden,
117        Self::WindowCreated,
118        Self::WindowDestroyed,
119        Self::WindowMovedOrResized,
120    ];
121
122    pub(crate) fn from_event_constant(event: u32) -> Option<Self> {
123        let ret = match event {
124            EVENT_SYSTEM_FOREGROUND => Some(Self::ForegroundWindowChanged),
125            EVENT_OBJECT_NAMECHANGE => Some(Self::WindowNameChanged),
126            EVENT_OBJECT_SHOW => Some(Self::WindowBecameVisible),
127            EVENT_OBJECT_HIDE => Some(Self::WindowBecameHidden),
128            EVENT_OBJECT_CREATE => Some(Self::WindowCreated),
129            EVENT_OBJECT_DESTROY => Some(Self::WindowDestroyed),
130            EVENT_OBJECT_LOCATIONCHANGE => Some(Self::WindowMovedOrResized),
131            _ => None,
132        };
133
134        // FIXME: move this to a test
135        if let Some(ret) = ret {
136            debug_assert_eq!(ret.event_constant(), event);
137        }
138
139        ret
140    }
141
142    pub(crate) fn event_constant(self) -> u32 {
143        match self {
144            Self::ForegroundWindowChanged => EVENT_SYSTEM_FOREGROUND,
145            Self::WindowNameChanged => EVENT_OBJECT_NAMECHANGE,
146            Self::WindowBecameVisible => EVENT_OBJECT_SHOW,
147            Self::WindowBecameHidden => EVENT_OBJECT_HIDE,
148            Self::WindowCreated => EVENT_OBJECT_CREATE,
149            Self::WindowDestroyed => EVENT_OBJECT_DESTROY,
150            Self::WindowMovedOrResized => EVENT_OBJECT_LOCATIONCHANGE,
151        }
152    }
153}
154
155unsafe extern "system" fn win_event_proc(
156    _h_win_event_hook: HWINEVENTHOOK,
157    event: u32,
158    hwnd: HWND,
159    id_object: i32,
160    id_child: i32,
161    _dw_event_thread: u32,
162    _dwms_event_time: u32,
163) {
164    if OBJECT_IDENTIFIER(id_object) == OBJID_WINDOW && id_child == CHILDID_SELF as _ {
165        let Some(kind) = WindowEventKind::from_event_constant(event) else {
166            return;
167        };
168
169        let snapshot = match WindowSnapshot::from_hwnd(hwnd) {
170            Ok(snapshot) => snapshot,
171            Err(_err) => {
172                // eprintln!("{err}"); // TODO: log error!
173                return;
174            }
175        };
176
177        if let Some(callback) = &STATE.lock().callback {
178            callback(WindowEvent { kind, snapshot });
179        }
180    }
181}
182
183/// A boxed closure/function pointer that provides [`WindowEvent`]s.
184pub type WindowEventCallback = Box<dyn Fn(WindowEvent) + Send>;
185
186struct WinHookState {
187    pub callback: Option<WindowEventCallback>,
188    pub thread: Option<(JoinHandle<Result<(), WinErr>>, WinThreadId)>,
189}
190
191static STATE: Mutex<WinHookState> = Mutex::new(WinHookState { callback: None, thread: None });
192
193/// Error returned by [`try_hook`].
194///
195/// Most likely, this will be caused by attempting to hook when a hook is already set,
196/// but in rare cases an error from the Win32 API may occur.
197#[derive(Debug, thiserror::Error, Eq, PartialEq)]
198pub enum TryHookError {
199    /// A hook was already previously set by this process.
200    /// To set another hook, first [unhook the previously set hook](unhook).
201    #[error("Hook already set; no need to set it again.")]
202    HookAlreadySet,
203    /// Internal error from Win32 API.
204    #[error("Failed to set hook: {0}")]
205    FailedToSetHook(WinErr),
206}
207
208/// Attempts to install a hook for monitoring [window events](WindowEvent).
209///
210/// Near this call (either before or after), you probably want to call [`set_callback`] to do something whenever the hook receives an event.
211/// Only one hook can be set at a time; attempting to set another hook will return [`TryHookError::HookAlreadySet`].
212pub fn try_hook() -> Result<(), TryHookError> {
213    let mut state = STATE.lock();
214
215    if state.thread.is_some() {
216        Err(TryHookError::HookAlreadySet)
217    } else {
218        match hook_inner() {
219            Ok(thread_id) => {
220                state.thread = Some(thread_id);
221
222                Ok(())
223            },
224            Err(err) => Err(TryHookError::FailedToSetHook(err)),
225        }
226    }
227}
228
229fn hook_inner() -> Result<(JoinHandle<Result<(), WinErr>>, WinThreadId), WinErr> {
230    let (tx, rx) = oneshot::channel();
231
232    let handle = std::thread::spawn(move || {
233        let event_const = WindowEventKind::ALL.map(WindowEventKind::event_constant);
234
235        let min = *event_const.iter().min().expect("should be at least one event kind");
236        let max = *event_const.iter().max().expect("should be at least one event kind");
237
238        // SAFETY: callback signature is correct, callback cannot capture locals due to being a fn ptr,
239        // event range is valid, thread will set up event loop
240        let hook = unsafe {
241            SetWinEventHook(min, max, None, Some(win_event_proc), 0, 0, WINEVENT_OUTOFCONTEXT)
242        };
243
244        let res = if hook.is_invalid() {
245            match WinErr::from_win32() {
246                err if err == WinErr::from(ERROR_INVALID_PARAMETER) => unreachable!("SetWinEventHook parameters should be correct"),
247                err if err == WinErr::from(ERROR_MOD_NOT_FOUND) => unreachable!("hmodwineventproc is null, so never should trigger this error"),
248                err if err == WinErr::from(ERROR_INVALID_THREAD_ID) => unreachable!("idthread is 0, so never should trigger this error"),
249                err if err == WinErr::from(ERROR_INVALID_FUNCTION) => unreachable!("function should have right signature & calling abi"),
250                err if err == WinErr::from(ERROR_PROC_NOT_FOUND) => unreachable!("not using a DLL"),
251                err => Err(err)
252            }
253        } else {
254            // SAFETY: always safe to call
255            let thread_id = unsafe { GetCurrentThreadId() };
256
257            Ok(WinThreadId::new(thread_id).expect("thread id should always be nonzero"))
258        };
259
260        tx.send(res).expect("rx should still exist");
261
262        let mut msg = MSG::default();
263
264        // SAFETY: msg is non-null & valid to write to (unique ptr due to &mut),
265        // and thread has a message queue to read from
266        match unsafe { GetMessageW(&mut msg, None, 0, 0) } {
267            BOOL(0) => {
268                assert_eq!(
269                    msg.message, WM_QUIT,
270                    // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessagew
271                    "If the function retrieves a message other than WM_QUIT, the return value is nonzero."
272                );
273
274                Ok(())
275            }
276            BOOL(-1) => match WinErr::from_win32() {
277                err if err == WinErr::from(ERROR_INVALID_WINDOW_HANDLE) => unreachable!("shouldn't trigger since hwnd is None"),
278                err if err == WinErr::from(ERROR_INVALID_PARAMETER) => unreachable!("should be calling GetMessageW with correct params"),
279                err => Err(err),
280            },
281            bool => unreachable!("message queue should not recv any other messages ({:?}, msg: {})", bool, msg.message),
282        }
283
284        // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-unhookwinevent
285        // "If the client's thread ends, the system automatically calls [UnhookWinEvent]"
286    });
287
288    rx.recv()
289        .expect("should eventually recv a message")
290        .map(|id| (handle, id))
291}
292
293/// Sets the callback called when a [window event](WindowEvent) occurs.
294///
295/// This function is how you can define what should happen upon a window event.
296/// Returns a previously set callback if it exists.
297///
298/// If you need to listen for the events in another location in your program,
299/// or need to collect them, you might want to set up a [channel](std::sync::mpsc).
300///
301/// # Panics
302/// If the callback provided ever panics, the program will panic as expected.
303///
304/// # Examples
305/// Debug print all* events:
306/// ```no_run
307/// wintrack::set_callback(Box::new(|evt| {
308///     // ignore events from zero-sized windows or windows with no title
309///     if evt.snapshot.rect.size() != (0, 0) && !evt.snapshot.title.is_empty() {
310///         dbg!(evt.snapshot);
311///     }
312/// }));
313/// ```
314/// Using a channel:
315/// ```no_run
316/// # use std::ffi::OsStr;
317/// # use wintrack::WindowEventKind;
318/// use std::sync::mpsc;
319///
320/// wintrack::try_hook().expect("hook should not be set yet");
321///
322/// let (tx, rx) = mpsc::channel();
323///
324/// wintrack::set_callback(Box::new(move |event| {
325///     let snapshot_exe = event.snapshot.executable.file_name();
326///     let is_firefox = snapshot_exe == Some(OsStr::new("firefox.exe"));
327///
328///     // only monitor name change events from Firefox
329///     // (this checks when the tab changes)
330///     if is_firefox && event.kind == WindowEventKind::WindowNameChanged {
331///         // send the event to the main thread
332///         let res = tx.send(event.snapshot);
333///
334///         if let Err(err) = res {
335///             // ...
336///  #          _ = err;
337///         }
338///     }
339/// }));
340///
341/// while let Ok(browser_snapshot) = rx.recv() {
342///     // ...
343/// #   _ = browser_snapshot;
344/// }
345/// ```
346pub fn set_callback(callback: WindowEventCallback) -> Option<WindowEventCallback> {
347    STATE.lock().callback.replace(callback)
348}
349
350/// Removes & returns the currently set callback if it exists.
351///
352/// A callback can be set using [`set_callback`].
353pub fn remove_callback() -> Option<WindowEventCallback> {
354    STATE.lock().callback.take()
355}
356
357/// Error returned by [`unhook`].
358///
359/// Most likely, this will be caused by attempting to unhook when no hook is sent.
360/// However, this can also error if there was a Win32 API error relating to the message queue.
361#[derive(Debug, thiserror::Error, Eq, PartialEq)]
362pub enum UnhookError {
363    /// No hook was set yet. To set a hook, use [`try_hook`].
364    #[error("No hook was set yet; call `try_hook()` to set a hook.")]
365    HookNotSet,
366    /// There was an error on the spawned hook thread (the thread that listens for events)
367    /// relating to the setup or shutdown of the event queue.
368    #[error("The hook thread failed: {0}")]
369    HookThreadError(WinErr),
370    /// There was an error with instructing the hook thread (thread that listens for events)
371    /// to quit. The [`unhook`] function failed to send [`WM_QUIT`].
372    #[error("Failed to quit to the hook thread (failed to send WM_QUIT): {0}")]
373    QuitMessageQueueError(WinErr),
374}
375
376/// Removes window event monitoring hook.
377///
378/// This function stops the thread that listens for [window events](WindowEvent).
379/// This *does not* call [`remove_callback`] to remove the set callback, but there's no harm in leaving it set.
380/// If a hook isn't set yet, this will return [`UnhookError::HookNotSet`].
381pub fn unhook() -> Result<(), UnhookError> {
382    let mut state = STATE.lock();
383
384    let Some((thread, thread_id)) = state.thread.take() else {
385        return Err(UnhookError::HookNotSet);
386    };
387
388    // SAFETY: thread is live and has message queue
389    match unsafe { PostThreadMessageW(thread_id.get(), WM_QUIT, WPARAM::default(), LPARAM::default()) } {
390        Ok(()) => match thread.join() {
391            Err(panic) => panic::resume_unwind(panic),
392            Ok(res) => match res {
393                Ok(()) => Ok(()),
394                Err(err) => Err(UnhookError::HookThreadError(err)),
395            }
396        }
397        Err(err) if err == WinErr::from(ERROR_INVALID_THREAD_ID) => panic!("WinHookState::thread should always point to a valid thread"),
398        Err(err) if err == WinErr::from(ERROR_INVALID_PARAMETER) => panic!("WinHookState::thread should always point to a valid thread"),
399        Err(err) => Err(UnhookError::QuitMessageQueueError(err)),
400    }
401}