ed063ab1fe
Capture desktop-specific system, session, Noctalia, and Pi integration alongside shared laptop support. Retire obsolete X11 configuration and make package ownership explicit for repeatable Decman deployments.
190 lines
6.2 KiB
Python
Executable File
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()
|