Skip to main content

ib_hook/process/
gui.rs

1#[cfg(feature = "sysinfo")]
2use std::path::Path;
3use std::{collections::HashMap, time::SystemTime};
4
5use bon::bon;
6
7use crate::{
8    process::Pid,
9    windows::shell::{ShellHook, ShellHookMessage},
10};
11
12/// Callback for GUI process events
13pub trait GuiProcessCallback: FnMut(GuiProcessEvent) + Send + 'static {}
14
15impl<T: FnMut(GuiProcessEvent) + Send + 'static> GuiProcessCallback for T {}
16
17/// Event types for GUI process monitoring.
18///
19/// These events are triggered by shell hook messages that indicate GUI process
20/// activity.
21///
22/// - For a process started after the watcher, [`CreateOrAlive`](Self::CreateOrAlive) must occur before
23///   [`Alive`](Self::Alive) with the same PID.
24#[derive(Debug, Clone, Copy)]
25pub enum GuiProcessEvent {
26    /// A new GUI process has been created, or an existing process is detected.
27    CreateOrAlive(Pid),
28
29    /// An existing GUI process is detected.
30    Alive(Pid),
31}
32
33impl GuiProcessEvent {
34    pub fn pid(&self) -> Pid {
35        match self {
36            GuiProcessEvent::CreateOrAlive(pid) => *pid,
37            GuiProcessEvent::Alive(pid) => *pid,
38        }
39    }
40}
41
42/**
43Monitors GUI processes, using the Windows shell hook API.
44
45## Examples
46```no_run
47use ib_hook::process::{GuiProcessEvent, GuiProcessWatcher};
48
49let watcher = GuiProcessWatcher::new(Box::new(|event| {
50    println!("Process event: {event:?}");
51})).unwrap();
52
53println!("Monitoring GUI processes...");
54std::thread::sleep(std::time::Duration::from_secs(60));
55```
56*/
57pub struct GuiProcessWatcher {
58    _shell: ShellHook,
59}
60
61#[bon]
62impl GuiProcessWatcher {
63    /// Creates a new GUI process watcher with the given callback.
64    ///
65    /// The callback will be called for each process event (window creation,
66    /// activation, rude activation, and replacement).
67    pub fn new(callback: impl GuiProcessCallback) -> windows::core::Result<Self> {
68        Self::with_on_hooked(callback, || ())
69    }
70
71    pub fn with_on_hooked(
72        mut callback: impl GuiProcessCallback,
73        on_hooked: impl FnOnce() + Send + 'static,
74    ) -> windows::core::Result<Self> {
75        let shell_callback = move |msg: ShellHookMessage| {
76            match msg {
77                ShellHookMessage::WindowCreated(hwnd) => {
78                    if let Ok(pid) = hwnd.try_into() {
79                        callback(GuiProcessEvent::CreateOrAlive(pid));
80                    }
81                }
82                ShellHookMessage::WindowActivated(hwnd)
83                | ShellHookMessage::RudeAppActivated(hwnd)
84                | ShellHookMessage::WindowReplacing(hwnd) => {
85                    if let Ok(pid) = hwnd.try_into() {
86                        callback(GuiProcessEvent::Alive(pid));
87                    }
88                }
89                _ => {}
90            }
91            false
92        };
93        let shell = ShellHook::with_on_hooked(Box::new(shell_callback), |_| on_hooked())?;
94        Ok(GuiProcessWatcher { _shell: shell })
95    }
96
97    /// Creates a new GUI process watcher with a deduplication buffer.
98    ///
99    /// This version deduplicates process events to avoid duplicate notifications
100    /// when multiple windows are created by the same process.
101    pub fn with_dedup(callback: impl GuiProcessCallback) -> windows::core::Result<Self> {
102        Self::with_filter_dedup(callback).filter(|_| true).build()
103    }
104
105    /// Creates a new GUI process watcher with a deduplication buffer and filters
106    /// to reduce syscalls.
107    ///
108    /// This version deduplicates process events to avoid duplicate notifications
109    /// when multiple windows are created by the same process.
110    #[builder(finish_fn = build)]
111    pub fn with_filter_dedup(
112        #[builder(start_fn)] mut callback: impl GuiProcessCallback,
113        #[builder(default)] create_only: bool,
114        mut filter: impl FnMut(GuiProcessEvent) -> bool + Send + 'static,
115        start_time_filter: Option<SystemTime>,
116        /// Call `callback` with every process and skip them afterwards.
117        existing_processes: Option<HashMap<Pid, SystemTime>>,
118    ) -> windows::core::Result<Self> {
119        // To deal with PID conflict
120        let mut dedup = match existing_processes {
121            Some(processes) => {
122                processes
123                    .keys()
124                    .for_each(|&pid| callback(GuiProcessEvent::CreateOrAlive(pid)));
125                processes
126            }
127            None => Default::default(),
128        };
129        /*
130        let shell_callback = move |msg: ShellHookMessage| {
131            match msg {
132                ShellHookMessage::WindowCreated(hwnd)
133                | ShellHookMessage::WindowActivated(hwnd)
134                | ShellHookMessage::RudeAppActivated(hwnd)
135                | ShellHookMessage::WindowReplacing(hwnd) => {
136                    if let Ok((pid, tid)) = Pid::from_hwnd_with_thread(hwnd) {
137                        debug!(%pid, tid);
138                        if filter(GuiProcessEvent::Alive(pid)) {
139                            dedup
140                                .entry(pid)
141                                .and_modify(|old_tid| {
142                                    if *old_tid != tid {
143                                        match Pid::from_tid(*old_tid) {
144                                            // The same process with new GUI thread
145                                            Ok(new_pid) if new_pid == pid => (),
146                                            // New thread with the same TID from new process
147                                            Ok(_) => {
148                                                ()
149                                            }
150                                            // Old thread died
151                                            Err(_) => {
152                                                // callback(GuiProcessEvent::Alive(pid));
153                                                // *old_tid = tid;
154                                                ()
155                                            }
156                                        }
157                                    }
158                                })
159                                .or_insert_with(|| {
160                                    callback(GuiProcessEvent::Alive(pid));
161                                    tid
162                                });
163                        }
164                    }
165                }
166                _ => (),
167            }
168            false
169        };
170        let shell = ShellHook::new(Box::new(shell_callback))?;
171        Ok(GuiProcessWatcher { _shell: shell })
172        */
173
174        let callback = move |event: GuiProcessEvent| {
175            if (!create_only || matches!(event, GuiProcessEvent::CreateOrAlive(_))) && filter(event)
176            {
177                let pid = event.pid();
178                // We need start_time to deal with PID conflict
179                let start_time = pid.get_start_time_or_max();
180                if start_time_filter.is_none_or(|f| start_time >= f) {
181                    dedup
182                        .entry(pid)
183                        .and_modify(|old_start_time| {
184                            if *old_start_time != start_time {
185                                callback(event);
186                                *old_start_time = start_time;
187                            }
188                        })
189                        .or_insert_with(|| {
190                            callback(event);
191                            start_time
192                        });
193                }
194            }
195        };
196        Self::new(callback)
197    }
198}
199
200#[cfg(feature = "sysinfo")]
201#[bon]
202impl GuiProcessWatcher {
203    /**
204    Apply a function on every existing and new GUI process exactly once.
205
206    Race condition / TOCTOU is handled in this function, although not perfect.
207    (Processes created after `start_time` before hooked will be lost,
208    but they can still be detected if they create new windows (and activate windows if `create_only` is `false`)
209    in the future, which is likely to happen.)
210
211    ## Examples
212    ```no_run
213    use ib_hook::process::GuiProcessWatcher;
214
215    let _watcher = GuiProcessWatcher::for_each(|pid| println!("pid: {pid}"))
216        .filter_image_path(|path| {
217            path.and_then(|p| p.file_name())
218                .is_some_and(|n| n.to_ascii_lowercase() == "notepad.exe")
219        })
220        .build();
221    std::thread::sleep(std::time::Duration::from_secs(60));
222    ```
223    */
224    #[builder(finish_fn = build)]
225    pub fn for_each(
226        #[builder(start_fn)] mut f: impl FnMut(Pid) + Send + 'static,
227        mut filter_image_path: impl FnMut(Option<&Path>) -> bool + Send + 'static,
228        /// Mitigate TOCTOU issue further at the cost of some system performance.
229        #[builder(default = true)]
230        create_only: bool,
231    ) -> windows::core::Result<Self> {
232        let start_time = SystemTime::now();
233
234        // TODO: Filter GUI processes?
235        // TODO: Avoid using sysinfo for this
236        let mut system = sysinfo::System::new();
237        system.refresh_processes_specifics(
238            sysinfo::ProcessesToUpdate::All,
239            true,
240            sysinfo::ProcessRefreshKind::nothing().with_exe(sysinfo::UpdateKind::Always),
241        );
242        let processes = system
243            .processes()
244            .values()
245            .filter(|process| filter_image_path(process.exe()))
246            .map(|process| process.pid().into())
247            .map(|pid: Pid| (pid, pid.get_start_time_or_max()))
248            .collect();
249
250        let watcher = {
251            Self::with_filter_dedup(move |event| {
252                let pid = event.pid();
253                if filter_image_path(pid.image_path().as_deref()) {
254                    f(pid)
255                }
256            })
257            .create_only(create_only)
258            .filter(|_| true)
259            .start_time_filter(start_time)
260            .existing_processes(processes)
261            .build()?
262        };
263
264        Ok(watcher)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    use std::{
273        sync::atomic::{AtomicUsize, Ordering},
274        thread,
275        time::Duration,
276    };
277
278    fn test_gui_process_watcher(d: Duration) {
279        println!("Testing GuiProcessWatcher - open/close some apps to see events");
280
281        let count = std::sync::Arc::new(AtomicUsize::new(0));
282
283        // Clone the Arc before moving into the closure
284        let count_result = count.clone();
285
286        let watcher = GuiProcessWatcher::new(Box::new(move |event: GuiProcessEvent| {
287            println!("Process event: {event:?}");
288            let pid = event.pid();
289            let count = count.fetch_add(1, Ordering::SeqCst);
290            println!("[{}] Process alive: {}", count + 1, pid);
291        }))
292        .expect("Failed to create GUI process watcher");
293
294        println!("GUI process watcher registered");
295        println!("Test will complete in {d:?} seconds...\n");
296
297        // Keep the watcher alive for a bit to receive events
298        thread::sleep(d);
299
300        // Drop watcher explicitly to demonstrate cleanup
301        drop(watcher);
302        println!("\nGUI process watcher destroyed.");
303        println!("Total events: {}", count_result.load(Ordering::SeqCst));
304    }
305
306    #[test]
307    fn gui_process_watcher() {
308        test_gui_process_watcher(Duration::from_secs(1))
309    }
310
311    #[ignore]
312    #[test]
313    fn gui_process_watcher_manual() {
314        test_gui_process_watcher(Duration::from_secs(30))
315    }
316
317    fn test_gui_process_watcher_dedup(d: Duration) {
318        println!("\nTesting GuiProcessWatcher with dedup - open/close some apps");
319
320        let count = std::sync::Arc::new(AtomicUsize::new(0));
321
322        // Clone the Arc before moving into the closure
323        let count_result = count.clone();
324
325        let watcher = GuiProcessWatcher::with_dedup(Box::new(move |event: GuiProcessEvent| {
326            println!("Process event: {event:?}");
327            let pid = event.pid();
328            let count = count.fetch_add(1, Ordering::SeqCst);
329            println!("[{}] Process alive (dedup): {}", count + 1, pid);
330        }))
331        .expect("Failed to create GUI process watcher with dedup");
332
333        println!("GUI process watcher with dedup registered");
334        println!("Test will complete in {d:?} seconds...\n");
335
336        thread::sleep(d);
337        drop(watcher);
338        println!("Total events: {}", count_result.load(Ordering::SeqCst));
339    }
340
341    #[test]
342    fn gui_process_watcher_dedup() {
343        test_gui_process_watcher_dedup(Duration::from_secs(1));
344    }
345
346    #[ignore]
347    #[test_log::test]
348    #[test_log(default_log_filter = "trace")]
349    fn gui_process_watcher_dedup_manual() {
350        test_gui_process_watcher_dedup(Duration::from_secs(60));
351    }
352
353    fn test_for_each(d: Duration) {
354        let _watcher = GuiProcessWatcher::for_each(|pid| println!("pid: {pid}"))
355            .filter_image_path(|path| {
356                path.and_then(|p| p.file_name())
357                    .is_some_and(|n| n.to_ascii_lowercase() == "notepad.exe")
358            })
359            .build();
360        thread::sleep(d);
361    }
362
363    #[test]
364    fn for_each() {
365        test_for_each(Duration::from_secs(1));
366    }
367
368    #[ignore]
369    #[test]
370    fn for_each_manual() {
371        test_for_each(Duration::from_secs(60));
372    }
373}