0

I've written a Windows service in Python that needs to be able to detect user activity. Under normal circumstances I would call the Windows GetLastInputInfo method, but that method doesn't work when called by a service. Here's the relevant info from the documentation for this method:

This function is useful for input idle detection. However, GetLastInputInfo does not provide system-wide user input information across all running sessions. Rather, GetLastInputInfo provides session-specific user input information for only the session that invoked the function.

The salient point is this: "for only the session that invoked the function"

If called by the service, GetLastInputInfo will always return 0 because the service is running in session 0 and doesn't receive input! How can my service detect user activity from the console session?

JRiggles
  • 4,847
  • 1
  • 12
  • 27

1 Answers1

1

Fortunately, there's a workaround for this problem! While you can't poll for user activity directly from a service, you can check to see if the system is currently handling user input by querying the I/O info for the Windows Client Server Runtime process (a.k.a. "csrss.exe").

By leveraging Python's psutil module, you can check the read_bytes property of csrss.exe. This value should change any time there is input from the user, i.e. keystrokes or mouse events.

First, you need to get the process ID (PID) for the csrss.exe process:

import psutil


def get_csrss_pids() -> list[int]:
    """Get the PID(s) for the Windows Client Server Runtime process"""
    # NOTE: more than one instance of csrss.exe may be running on your 
    # machine, so you'll want to gather all of the matching PIDs!
    return [
        proc.pid for proc in psutil.process_iter(attrs=['name'])
        if proc.name() == 'csrss.exe'
    ]

Once you have your csrss.exe PID(s), you can use psutil's io_counters method to get the read_bytes info

def get_io(pids: list[int]) -> list[int]:
    """Returns the last `read_bytes` value for the given csrss.exe PID(s)"""
    # NOTE: if multiple PIDs are given, it's likely that only one of the PIDs
    # 'read_bytes' values will be changing on user input because one of these
    # processes is for your current session and the others aren't
    return [psutil.Process(pid).io_counters().read_bytes for pid in pids]

The get_io function will return a list of integers corresponding to the read_bytes values for each of the given csrss.exe process IDs. To check for user activity, this list should be periodically compared to a previously stored value - any changes mean there's been input from the user!

Here's a quick demo:

import psutil


def get_csrss_pids() -> list[int]:
    """Get the PID(s) for the Windows Client Server Runtime process"""
    return [
        proc.pid for proc in psutil.process_iter(attrs=['name'])
        if proc.name() == 'csrss.exe'
    ]


def get_io(pids: list[int]) -> list[int]:
    """Returns the last `read_bytes` value for the given csrss.exe PID(s)"""
    return [psutil.Process(pid).io_counters().read_bytes for pid in pids]


pids = get_csrss_pids()
last_io = get_io(pids)  # store an initial value to compare against

while True:
    try:
        if (tick := get_io(pids)) != last_io:  # if activity is detected...
            print(tick)  # do something
            last_io = tick  # store the new value to compare against
    except KeyboardInterrupt:
        break

To incorporate these functions into your service, simply include them in your main class (the one subclassing ServiceFramework) - don't forget to add the self parameter!

You'll want to call get_csrss_pids and set the initial value of last_io at __init__ and go from there:

class MyService(ServiceFramework):
    _svc_name_ = 'my_service'
    _svc_display_name_ = 'My Service'

    def __init__(self, *args):
        super().__init__(*args)
        self.csrss_pids = self.get_csrss_pids()
        self.last_io = self.get_io()
        ...
JRiggles
  • 4,847
  • 1
  • 12
  • 27