#!/usr/bin/env python3 """Track session ScreenSaver inhibitors and expose combined idle blockers as TSV. logind exposes idle inhibitors through systemd-inhibit. Browser video wake locks use org.freedesktop.ScreenSaver instead, which has no list API, so this monitor observes Inhibit/UnInhibit calls and keeps their active state in a small JSON file for the Noctalia widget and panel. """ from __future__ import annotations import argparse import json import os import re import signal import subprocess import sys from pathlib import Path from tempfile import NamedTemporaryFile STATE_FILE = Path.home() / ".local/state/noctalia/idle-inhibitors.json" SCREEN_SAVER = "org.freedesktop.ScreenSaver" def write_state(inhibitors: dict[tuple[str, int], dict[str, str]]) -> None: STATE_FILE.parent.mkdir(parents=True, exist_ok=True) payload = {"inhibitors": list(inhibitors.values())} with NamedTemporaryFile("w", dir=STATE_FILE.parent, delete=False) as file: json.dump(payload, file, separators=(",", ":")) file.write("\n") temporary_name = file.name os.replace(temporary_name, STATE_FILE) def read_screensaver_inhibitors() -> list[dict[str, str]]: try: payload = json.loads(STATE_FILE.read_text()) except (FileNotFoundError, json.JSONDecodeError): return [] inhibitors = payload.get("inhibitors", []) return [item for item in inhibitors if isinstance(item, dict)] def logind_inhibitors() -> list[dict[str, str]]: result = subprocess.run( ["systemd-inhibit", "--list", "--no-pager"], check=False, capture_output=True, text=True, ) if result.returncode != 0: return [] inhibitors = [] for line in result.stdout.splitlines()[1:]: fields = line.split(maxsplit=6) if len(fields) < 7 or "idle" not in fields[5].split(":"): continue reason_and_mode = fields[6].rsplit(maxsplit=1) inhibitors.append( { "source": "logind", "who": fields[0], "user": fields[2], "pid": fields[3], "comm": fields[4], "mode": reason_and_mode[-1], "why": reason_and_mode[0] if len(reason_and_mode) == 2 else "No reason supplied", } ) return inhibitors def combined_inhibitors() -> list[dict[str, str]]: return logind_inhibitors() + read_screensaver_inhibitors() def print_inhibitors() -> None: for inhibitor in combined_inhibitors(): values = [ inhibitor.get("source", "screensaver"), inhibitor.get("who", ""), inhibitor.get("user", ""), inhibitor.get("pid", ""), inhibitor.get("comm", ""), inhibitor.get("mode", ""), inhibitor.get("why", ""), ] print("\t".join(value.replace("\t", " ").replace("\n", " ") for value in values)) def monitor() -> None: active: dict[tuple[str, int], dict[str, str]] = {} pending: dict[tuple[str, int], dict[str, str]] = {} write_state(active) process = subprocess.Popen( ["stdbuf", "-oL", "busctl", "--user", "--json=short", "monitor"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, ) def stop(_signal: int, _frame: object) -> None: process.terminate() write_state({}) raise SystemExit(0) signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) assert process.stdout is not None for line in process.stdout: try: message = json.loads(line) except json.JSONDecodeError: continue message_type = message.get("type") sender = message.get("sender", "") payload = message.get("payload", {}).get("data", []) if ( message_type == "method_call" and message.get("interface") == SCREEN_SAVER and message.get("member") == "Inhibit" and len(payload) >= 2 ): pending[(sender, int(message.get("cookie", 0)))] = { "source": "screensaver", "who": str(payload[0]), "user": "session", "pid": "", "comm": str(payload[0]), "mode": "block", "why": str(payload[1]), } elif ( message_type == "method_return" and payload and isinstance(payload[0], int) ): request = (message.get("destination", ""), int(message.get("reply_cookie", 0))) inhibitor = pending.pop(request, None) if inhibitor is not None: active[(request[0], int(payload[0]))] = inhibitor write_state(active) elif ( message_type == "method_call" and message.get("interface") == SCREEN_SAVER and message.get("member") == "UnInhibit" and payload and isinstance(payload[0], int) ): cookie = int(payload[0]) active.pop((sender, cookie), None) # Some clients reconnect before releasing a cookie; the cookie is # globally unique for Noctalia, so clean up that fallback as well. for key in [key for key in active if key[1] == cookie]: active.pop(key, None) write_state(active) elif ( message_type == "signal" and message.get("interface") == "org.freedesktop.DBus" and message.get("member") == "NameOwnerChanged" and len(payload) == 3 and payload[2] == "" ): vanished = str(payload[0]) for key in [key for key in active if key[0] == vanished]: active.pop(key, None) for key in [key for key in pending if key[0] == vanished]: pending.pop(key, None) write_state(active) parser = argparse.ArgumentParser() parser.add_argument("--count", action="store_true", help="print combined inhibitor count") parser.add_argument("--list", action="store_true", help="print combined inhibitors as TSV") arguments = parser.parse_args() if arguments.count: print(len(combined_inhibitors())) elif arguments.list: print_inhibitors() else: monitor()