From 5f62e743b360b6c88c204633fb5f7c719ca326b5 Mon Sep 17 00:00:00 2001 From: Mingye Wang Date: Wed, 12 Nov 2025 00:02:23 +0800 Subject: [PATCH 1/3] Add macOS support, and more * __init__.py: * macOS support via ioreg * expand debug to support escalating levels. at level 2 we print the exception trace, which turned out useful for debugging macOS support * __main__.py: * new -l switch to list the available backends * expand -d switch to allow repeating and escalating debug level * actually calls main() to make python -m dbus_idle work * pyproject.toml: * avoid installing jeepney on macOS * README.md: add "macOS" and rephrase --- README.md | 2 +- dbus_idle/__init__.py | 63 +++++++++++++++++++++++++++++++++++-------- dbus_idle/__main__.py | 32 ++++++++++++++++++++-- pyproject.toml | 2 +- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8f8392f..fd41e0a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![python version](https://img.shields.io/pypi/pyversions/dbus-idle.svg) ![license](https://img.shields.io/pypi/l/dbus-idle.svg) -Python library to detect user idle time in milliseconds or inactivity on Linux and Windows. +Python library to detect user idle time in milliseconds (and from that, inactivity) on Linux, Windows, and macOS. ## Requirements diff --git a/dbus_idle/__init__.py b/dbus_idle/__init__.py index 3ba6f20..a459286 100644 --- a/dbus_idle/__init__.py +++ b/dbus_idle/__init__.py @@ -1,3 +1,4 @@ +from sys import stdout import time import ctypes import ctypes.util @@ -12,10 +13,11 @@ class IdleMonitor: subclasses: List[Type["IdleMonitor"]] = [] - def __init__(self, *, idle_threshold: int = 120, debug: bool=False) -> None: + def __init__(self, *, idle_threshold: int = 120, debug: int | bool = 0) -> None: self.idle_threshold = idle_threshold self.class_used = None - if debug: + self.debug = int(debug) + if self.debug: logger.setLevel(logging.DEBUG) def __init_subclass__(self) -> None: @@ -34,7 +36,7 @@ def get_monitor(self, **kwargs) -> "IdleMonitor": logger.warning("Could not load %s", monitor_class, exc_info=True) raise RuntimeError("Could not find a working monitor.") - def get_dbus_idle(self) -> float: + def get_dbus_idle(self) -> float | None: """ Return idle time in milliseconds. """ @@ -42,17 +44,27 @@ def get_dbus_idle(self) -> float: for monitor_class in self.subclasses: try: self.class_used = monitor_class() - logger.debug("Using: %s", monitor_class.__name__) + logger.info("Using: %s", monitor_class.__name__) return self.class_used.get_dbus_idle() except Exception: - logger.info("Could not load %s", monitor_class.__name__, exc_info=False) - logger.warning("Could not find any working monitor to get idle time.", exc_info=True) + logger.debug( + "Could not load %s", + monitor_class.__name__, + exc_info=self.debug > 1, + ) + logger.warning( + "Could not find any working monitor to get idle time.", + exc_info=False, + ) return None else: try: return self.class_used.get_dbus_idle() - except Exception as e: - logger.warning("Can't run the working monitor enymore.", exc_info=False) + except Exception: + logger.warning( + "Can't run the working monitor enymore.", + exc_info=self.debug > 1, + ) return None def is_idle(self) -> bool: @@ -64,7 +76,7 @@ def is_idle(self) -> bool: class DBusIdleMonitor(IdleMonitor): """ - Idle monitor for gnome running on wayland. + Idle monitor for gnome running on wayland (DBus IdleMonitor). Based on * https://unix.stackexchange.com/a/492328 @@ -123,7 +135,7 @@ def get_dbus_idle(self) -> float: class X11IdleMonitor(IdleMonitor): """ - Idle monitor for systems running X11. + Idle monitor for systems running X11 (XScreenSaverInfo). Based on * http://tperl.blogspot.com/2007/09/x11-idle-time-and-focused-window-in.html @@ -212,7 +224,7 @@ def get_dbus_idle(self) -> float: class WindowsIdleMonitor(IdleMonitor): """ - Idle monitor for Windows. + Idle monitor for Windows (GetLastInputInfo). Based on * https://stackoverflow.com/q/911856 @@ -227,3 +239,32 @@ def get_dbus_idle(self) -> float: current_tick = self.win32api.GetTickCount() last_tick = self.win32api.GetLastInputInfo() return current_tick - last_tick + + class IORegIdleMonitor(IdleMonitor): + """ + Idle monitor for macOS (IOHIDSystem.HIDIdleTime). + + Based on + * https://stackoverflow.com/a/17966890 + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + import plistlib + self.plistlib = plistlib + # make sure we can run + self.get_dbus_idle() + + def get_dbus_idle(self) -> float: + command = subprocess.run( + ["ioreg", "-arc", "IOHIDSystem"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if command.returncode != 0: + raise RuntimeError( + f"ioreg -arc IOHIDSystem returned {command.returncode}." + ) + plist = self.plistlib.loads(command.stdout) + + return plist[0]["HIDIdleTime"] / 1_000_000 diff --git a/dbus_idle/__main__.py b/dbus_idle/__main__.py index 1c7fb3f..3a69c1e 100644 --- a/dbus_idle/__main__.py +++ b/dbus_idle/__main__.py @@ -2,13 +2,41 @@ import argparse +def skim_docstring(im: type[IdleMonitor]) -> str: + """Return the first line of the given IdleMonitor subclass.""" + doc = im.__doc__ + if doc is None: + return "No description available." + doclines = doc.strip().split("\n") + if len(doclines) == 0: + return "No description available." + return doclines[0] + + def main(): parser = argparse.ArgumentParser( prog="dbus-idle", - description="Get idle time in seconds from DBus") - parser.add_argument("-d", "--debug", action="store_true", help="Show debug messeges") + description="Get idle time in seconds from one of several sources.") + parser.add_argument( + "-d", + "--debug", + action="count", + help="Show debug messeges (repeat for exception trace)", + default=0, + ) + parser.add_argument( + "-l", "--list", action="store_true", help="List available monitors" + ) args = parser.parse_args() idle_monitor = IdleMonitor(debug=args.debug) + if args.list: + print("Available monitors:") + for monitor_class in IdleMonitor.subclasses: + print(f" - {monitor_class.__name__}: {skim_docstring(monitor_class)}") milliseconds = idle_monitor.get_dbus_idle() print(milliseconds) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 6c8dd53..10e506c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "jeepney>=0.9.0 ; platform_system != 'Windows'", + "jeepney>=0.9.0 ; platform_system not in ('Windows', 'Darwin')", "pywin32>=221 ; platform_system == 'Windows'", ] From 5dc66cf1620ce272ed4946c2653e14d5afdbd6fc Mon Sep 17 00:00:00 2001 From: Mingye Wang Date: Thu, 13 Nov 2025 10:18:57 +0800 Subject: [PATCH 2/3] README: non-Linux --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index fd41e0a..5f7efc4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ Python library to detect user idle time in milliseconds (and from that, inactivi ## Requirements * Python 3.7 or later +* One of the following: + * D-Bus with `org.freedesktop.DBus` interface + * `xprintidle` command + * libX11 with `XScreenSaverInfo` + * `swayidle` command + * Windows + * macOS ## Installation @@ -20,6 +27,10 @@ sudo apt install meson libdbus-glib-1-dev patchelf pip install dbus-idle ``` +On non-Linux systems, +``` +pip install dbus-idle +``` ## Usage From e4594ec15e53e3d139c4c71ed3e750ac5ab1d0ae Mon Sep 17 00:00:00 2001 From: Mingye Wang Date: Thu, 13 Nov 2025 10:20:03 +0800 Subject: [PATCH 3/3] Windows: handle wraparound of u32 --- dbus_idle/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dbus_idle/__init__.py b/dbus_idle/__init__.py index a459286..8878fef 100644 --- a/dbus_idle/__init__.py +++ b/dbus_idle/__init__.py @@ -236,9 +236,10 @@ def __init__(self, **kwargs) -> None: self.win32api = win32api def get_dbus_idle(self) -> float: - current_tick = self.win32api.GetTickCount() last_tick = self.win32api.GetLastInputInfo() - return current_tick - last_tick + current_tick = self.win32api.GetTickCount() + # Handle wraparound + return current_tick - last_tick + (2**32 if current_tick < last_tick else 0) class IORegIdleMonitor(IdleMonitor): """