#[cfg(feature = "sysinfo")]
use std::path::Path;
use std::{collections::HashMap, time::SystemTime};
use bon::bon;
use crate::{
process::Pid,
windows::shell::{ShellHook, ShellHookMessage},
};
pub trait GuiProcessCallback: FnMut(GuiProcessEvent) + Send + 'static {}
impl<T: FnMut(GuiProcessEvent) + Send + 'static> GuiProcessCallback for T {}
#[derive(Debug, Clone, Copy)]
pub enum GuiProcessEvent {
CreateOrAlive(Pid),
Alive(Pid),
}
impl GuiProcessEvent {
pub fn pid(&self) -> Pid {
match self {
GuiProcessEvent::CreateOrAlive(pid) => *pid,
GuiProcessEvent::Alive(pid) => *pid,
}
}
}
pub struct GuiProcessWatcher {
_shell: ShellHook,
}
#[bon]
impl GuiProcessWatcher {
pub fn new(callback: impl GuiProcessCallback) -> windows::core::Result<Self> {
Self::with_on_hooked(callback, || ())
}
pub fn with_on_hooked(
mut callback: impl GuiProcessCallback,
on_hooked: impl FnOnce() + Send + 'static,
) -> windows::core::Result<Self> {
let shell_callback = move |msg: ShellHookMessage| {
match msg {
ShellHookMessage::WindowCreated(hwnd) => {
if let Ok(pid) = hwnd.try_into() {
callback(GuiProcessEvent::CreateOrAlive(pid));
}
}
ShellHookMessage::WindowActivated(hwnd)
| ShellHookMessage::RudeAppActivated(hwnd)
| ShellHookMessage::WindowReplacing(hwnd) => {
if let Ok(pid) = hwnd.try_into() {
callback(GuiProcessEvent::Alive(pid));
}
}
_ => {}
}
false
};
let shell = ShellHook::with_on_hooked(Box::new(shell_callback), |_| on_hooked())?;
Ok(GuiProcessWatcher { _shell: shell })
}
pub fn with_dedup(callback: impl GuiProcessCallback) -> windows::core::Result<Self> {
Self::with_filter_dedup(callback).filter(|_| true).build()
}
#[builder(finish_fn = build)]
pub fn with_filter_dedup(
#[builder(start_fn)] mut callback: impl GuiProcessCallback,
#[builder(default)] create_only: bool,
mut filter: impl FnMut(GuiProcessEvent) -> bool + Send + 'static,
start_time_filter: Option<SystemTime>,
existing_processes: Option<HashMap<Pid, SystemTime>>,
) -> windows::core::Result<Self> {
let mut dedup = match existing_processes {
Some(processes) => {
processes
.keys()
.for_each(|&pid| callback(GuiProcessEvent::CreateOrAlive(pid)));
processes
}
None => Default::default(),
};
let callback = move |event: GuiProcessEvent| {
if (!create_only || matches!(event, GuiProcessEvent::CreateOrAlive(_))) && filter(event)
{
let pid = event.pid();
let start_time = pid.get_start_time_or_max();
if start_time_filter.is_none_or(|f| start_time >= f) {
dedup
.entry(pid)
.and_modify(|old_start_time| {
if *old_start_time != start_time {
callback(event);
*old_start_time = start_time;
}
})
.or_insert_with(|| {
callback(event);
start_time
});
}
}
};
Self::new(callback)
}
}
#[cfg(feature = "sysinfo")]
#[bon]
impl GuiProcessWatcher {
#[builder(finish_fn = build)]
pub fn for_each(
#[builder(start_fn)] mut f: impl FnMut(Pid) + Send + 'static,
mut filter_image_path: impl FnMut(Option<&Path>) -> bool + Send + 'static,
#[builder(default = true)]
create_only: bool,
) -> windows::core::Result<Self> {
let start_time = SystemTime::now();
let mut system = sysinfo::System::new();
system.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::All,
true,
sysinfo::ProcessRefreshKind::nothing().with_exe(sysinfo::UpdateKind::Always),
);
let processes = system
.processes()
.values()
.filter(|process| filter_image_path(process.exe()))
.map(|process| process.pid().into())
.map(|pid: Pid| (pid, pid.get_start_time_or_max()))
.collect();
let watcher = {
Self::with_filter_dedup(move |event| {
let pid = event.pid();
if filter_image_path(pid.image_path().as_deref()) {
f(pid)
}
})
.create_only(create_only)
.filter(|_| true)
.start_time_filter(start_time)
.existing_processes(processes)
.build()?
};
Ok(watcher)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
sync::atomic::{AtomicUsize, Ordering},
thread,
time::Duration,
};
fn test_gui_process_watcher(d: Duration) {
println!("Testing GuiProcessWatcher - open/close some apps to see events");
let count = std::sync::Arc::new(AtomicUsize::new(0));
let count_result = count.clone();
let watcher = GuiProcessWatcher::new(Box::new(move |event: GuiProcessEvent| {
println!("Process event: {event:?}");
let pid = event.pid();
let count = count.fetch_add(1, Ordering::SeqCst);
println!("[{}] Process alive: {}", count + 1, pid);
}))
.expect("Failed to create GUI process watcher");
println!("GUI process watcher registered");
println!("Test will complete in {d:?} seconds...\n");
thread::sleep(d);
drop(watcher);
println!("\nGUI process watcher destroyed.");
println!("Total events: {}", count_result.load(Ordering::SeqCst));
}
#[test]
fn gui_process_watcher() {
test_gui_process_watcher(Duration::from_secs(1))
}
#[ignore]
#[test]
fn gui_process_watcher_manual() {
test_gui_process_watcher(Duration::from_secs(30))
}
fn test_gui_process_watcher_dedup(d: Duration) {
println!("\nTesting GuiProcessWatcher with dedup - open/close some apps");
let count = std::sync::Arc::new(AtomicUsize::new(0));
let count_result = count.clone();
let watcher = GuiProcessWatcher::with_dedup(Box::new(move |event: GuiProcessEvent| {
println!("Process event: {event:?}");
let pid = event.pid();
let count = count.fetch_add(1, Ordering::SeqCst);
println!("[{}] Process alive (dedup): {}", count + 1, pid);
}))
.expect("Failed to create GUI process watcher with dedup");
println!("GUI process watcher with dedup registered");
println!("Test will complete in {d:?} seconds...\n");
thread::sleep(d);
drop(watcher);
println!("Total events: {}", count_result.load(Ordering::SeqCst));
}
#[test]
fn gui_process_watcher_dedup() {
test_gui_process_watcher_dedup(Duration::from_secs(1));
}
#[ignore]
#[test_log::test]
#[test_log(default_log_filter = "trace")]
fn gui_process_watcher_dedup_manual() {
test_gui_process_watcher_dedup(Duration::from_secs(60));
}
#[cfg(feature = "sysinfo")]
fn test_for_each(d: Duration) {
let _watcher = GuiProcessWatcher::for_each(|pid| println!("pid: {pid}"))
.filter_image_path(|path| {
path.and_then(|p| p.file_name())
.is_some_and(|n| n.to_ascii_lowercase() == "notepad.exe")
})
.build();
thread::sleep(d);
}
#[cfg(feature = "sysinfo")]
#[test]
fn for_each() {
test_for_each(Duration::from_secs(1));
}
#[cfg(feature = "sysinfo")]
#[ignore]
#[test]
fn for_each_manual() {
test_for_each(Duration::from_secs(60));
}
}