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}