diff --git a/README.md b/README.md index 8f8392f..5f7efc4 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,19 @@ ![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 * 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 diff --git a/dbus_idle/__init__.py b/dbus_idle/__init__.py index 3ba6f20..8878fef 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 @@ -224,6 +236,36 @@ 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): + """ + 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'", ]