Files
dotfiles/.local/bin/idle-inhibitor-monitor
T

190 lines
6.2 KiB
Python
Executable File

#!/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()