09 Nov 2023
Josh

Tracking the current active process in Windows with Rust

If you want to write a program that tracks your activity on Windows, like I'm doing with Foreground, the core of that tracking is finding out which process owns the currently active window. Only one window is the active window at any one time, and Windows provides an API function to tell us which one that is.

Fortunately, Microsoft also publishes official Rust bindings for Windows with the windows crate. So we can call any Windows API from Rust and import all the necessary data types we need. This means we have everything we need to get the job done in Rust.

First we'll need some dependencies in our Cargo.toml:

[dependencies]
sysinfo = "^0"
tokio = { version = "^1.19", features = ["rt", "time"] }

[dependencies.windows]
version = "^0.51"
features = [    
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
    "Win32_System_SystemInformation",
    "Win32_UI_WindowsAndMessaging",
    "Win32_UI_Input_KeyboardAndMouse",
]

The sysinfo crate provides a convenient wrapper around getting all the running processes, so I'm choosing to use this too, instead of writing my own wrapper around the windows crate's native calls. Thanks sysinfo! We need tokio as we're using it to run our background loop for continual tracking.

Now that we have the dependencies we need, let's write our wrappers around the windows crate calls. All of the relevant functions are marked as unsafe, so we need to call them inside an unsafe block too.

First let's get the active window. I'll put this in windows.rs.

use windows::{
    Win32::UI::WindowsAndMessaging::{
        GetForegroundWindow, GetWindowTextW, GetWindowThreadProcessId,
    },
};

pub fn get_active_window() -> (u32, String) {
    unsafe {
        let hwnd = GetForegroundWindow();

        let mut pid: u32 = 0;
        GetWindowThreadProcessId(hwnd, Some(&mut pid));
        
        let mut bytes: [u16; 500] = [0; 500];
        let len = GetWindowTextW(hwnd, &mut bytes);
        let title = String::from_utf16_lossy(&bytes[..len as usize]);
        
        (pid, title)
    }
}

We get the handle to our window with GetForegroundWindow() (hey, that's the name of our app) but then we want to exchange that handle for a process ID (PID) with GetWindowThreadProcessId. A window handle isn't really useful here, but that PID will come in handy later. Finally we get the window title with GetWindowTextW, which gives us some icky UTF16 bytes. We can convert them to a String type and voila, we have the active process's ID and the title of the window! We don't strictly need the window title, but I'll show you how we'll use it later.

That's one piece of the puzzle, but we want to know the process name as well. We needed that PID to look up the process, so now that we have it, let's proceed to use it to get the process name.

Here's our function to get the running process. This time we'll work in main.rs.

use crate::windows;
use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt};

pub fn get_process() {
    
    let mut sys = System::new_all();
    
    let (window_pid, window_title) = windows::get_active_window();

    if window_pid == 0 {
        // idle process
        return;
    }

    let process = sys.processes().get(&Pid::from_u32(window_pid));

    if let Some(process) = process {
        let name = format_name(window_title.as_str(), process.name());
        
        // here you'd do the work to store the name, timestamp, and so on
    }
}

We create a new System object from our sysinfo crate, and this contains all the current process data, among other things. Then we use our get_active_window function to get us the active window's PID. 0 means the system idle process, which is what's returned if there's a screensaver, the screen is off, or you're sitting on the login screen — when the computer is idle. We don't want to track that.

Next sys.processes() gives us a hashmap of Process objects with PIDs as keys. From that process we can get the process.name() for our PID, and you'll see here I'm doing some further formatting to decide what name I want to call the process. Let's have a look at format_name.

fn format_name<'a>(
    window_name: &'a str,
    process_name: &'a str,
) -> &'a str {

    if process_name.eq("ApplicationFrameHost.exe") {
        return window_name;
    }

    process_name
}

This is pretty simple, and we're just using it to account for one thing — UWP (Universal Windows Platform) apps. These are written using .NET and don't run their own process, but are managed by ApplicationFrameHost. So if the process name matches this, we use the window title instead, as this is more accurate. Easy!

So, to recap, we've started with asking Windows for the active window's PID and title and we've used that to look up the details of a running process, getting its name. Now when we call get_process(), we'll get the name of whatever is in the foreground — notepad.exe, for example, or Calculator rather than calc.exe, because it's a UWP app.

The final piece of the puzzle is to run this in a loop so that we can constantly track the active window. However, we want to pause tracking if the user stops input. We don't want to track activity if the screen is on but nobody's there, which is a fair description of me when I am very tired.

Back in our windows.rs file we add a function to get the time since last input.

use std::time::Duration;
use windows::{
    Win32::System::SystemInformation::GetTickCount,
    Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO},
};

pub fn get_last_input() -> Duration {
    let tick_count = unsafe { GetTickCount() };
    let mut last_input_info = LASTINPUTINFO {
        cbSize: 8, // Probably only true for 64 bit systems?
        dwTime: 0,
    };

    let p_last_input_info = &mut last_input_info as *mut LASTINPUTINFO;

    let _success = unsafe { GetLastInputInfo(p_last_input_info) };
    let diff = tick_count - last_input_info.dwTime;
    return Duration::from_millis(diff.into());
}

I'm sure I found this code somewhere online, probably in a Stack Overflow answer, because the arcane incantations to summon a valid LASTINPUTINFO don't mean anything to me. Thank you, mysterious code provider. But the gist here is that we ask Windows for the "tick count", which is how long the computer has been running in milliseconds, and then we ask Windows for info on the last input. Our dwTime represents the last time there was input as an incrementing millisecond count too, so we can get the difference to find the duration in milliseconds since last input.

Okay! Now our loop can check and take into account the last input time, so that we stop tracking when the computer is idle.

Back in main.rs it will look something like this.

use std::time::Duration;
use tokio::time;
use crate::windows;

const IDLE_CHECK_SECS: i32 = 5;
const IDLE_PERIOD: u64 = 30;

pub async fn track_processes() {
    let mut interval = time::interval(Duration::from_secs(1));

    let mut i = 0;
    let mut idle = false;

    loop {
        i = i + 1;
        interval.tick().await;

        if i == IDLE_CHECK_SECS {
            // we check that the last time the user made any input
            // was shorter ago than our idle period.
            // if it wasn't, we pause tracking
            let duration = windows::get_last_input().as_secs();
            if IDLE_PERIOD > 0 && duration > IDLE_PERIOD {
                idle = true;
            } else {
                idle = false;
            }
            i = 0;
        }

        if !idle {
            get_process().await;
        }
    }
}

We use Tokio's interval function to make sure that our loop waits one second each time through. From there, we have two periods we care about for the idle checking in the loop. We don't want to check every single second if the computer is idle, because that's wasteful, but the less often we check, the longer we keep tracking erroneously when we should have switched our state to idle. So here we use an IDLE_CHECK_SECS of five seconds as a compromise.

The second period is how long to allow a lack of input before deciding the computer is idle. Too short, and the user can't have any time tracked while they're sitting and staring at the screen. Too long, and we'll keep tracking long after the user has walked away to make a coffee. So our compromise IDLE_PERIOD is thirty seconds.

Putting together these two periods, we run our loop every second, call get_last_input every five seconds to get a duration that has passed since last input, and check if that period is greater than our idle period of thirty seconds. If it is, we stop tracking. If not, the computer is still active, so we track the active process.

And that's the whole process complete! Get the active window, find the process that owns it, and do all of that in a loop as long as the user or the computer isn't idle. We need only to tokio::spawn a task to run track_processes() forever, and we'll keep on trackin'.

I've been very happy with the decision to write this in Rust, using Tauri for the interface. All of this code comes from Foreground, our fully local and private activity tracker for Windows.