diff --git a/PKGBUILD b/PKGBUILD index 46e0fe8..6668cf5 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -3,7 +3,7 @@ # Maintainer: Daniele Fucini pkgname=xautolock-inhibit -pkgver=0.1.2 +pkgver=0.2 pkgrel=1 pkgdesc='Honor org.freedesktop.ScreenSaver inhibit requests by disabling xautolock' arch=('any') diff --git a/README.md b/README.md index da05148..1b8201b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ xautolock stays your locker; xautolock-inhibit only switches it off and on. - Runs `xautolock` only when the number of active requests goes from 0 to 1 or back to 0. Re-enabling waits a few seconds (`--release-delay`), because browsers send short bursts of requests when a video starts. - Pauses X blanking with `XScreenSaverSuspend`, the same mechanism mpv uses, instead of changing `xset` settings. The X server ends the pause by itself if the daemon exits or crashes. - Requests whose reason is on an ignore list (default: `Playing audio`) are accepted and logged, but don't block locking. Firefox-based browsers send one for audio playback, which would otherwise keep the screen unlocked while music plays with nobody at the computer. +- Keeps the requests it holds in `$XDG_RUNTIME_DIR/xautolock-inhibit.json`, so a restarted or crashed daemon takes them over. They are taken over only if the previous daemon stopped at most 30 seconds ago (`--restore-window`) on the same session bus, and only for clients still connected. A request released while no daemon was running can't be seen, so an old file isn't trusted. - On exit (including SIGTERM), it re-enables xautolock. The systemd unit also runs `xautolock -enable` when it stops, as a fallback. - If another program (a desktop environment) already owns `org.freedesktop.ScreenSaver`, it exits with status 2 instead of taking over. @@ -45,12 +46,13 @@ The unit has no `[Install]` section on purpose. Enabling it at login would also ## Usage ``` -xautolock-inhibit [-i REASON] [--count-all] [-d SECONDS] +xautolock-inhibit [-i REASON] [--count-all] [-d SECONDS] [--restore-window SECONDS] ``` - `-i`, `--ignore-reason REASON`: request reason that doesn't block locking. Can be repeated; replaces the default (`Playing audio`). - `--count-all`: every request blocks locking; no reasons are ignored. - `-d`, `--release-delay SECONDS`: wait this long after the last release before re-enabling xautolock (default 5). +- `--restore-window SECONDS`: take over requests from a previous daemon that stopped at most this long ago (default 30; 0 disables). To change the options used by the service: @@ -84,7 +86,7 @@ journalctl --user -u xautolock-inhibit ## Limitations -- Restarting the daemon loses the requests it holds; apps don't send them again. Avoid restarting it while a video or game is running. +- A release sent while the daemon is down is lost: if the daemon is back within `--restore-window`, that request stays active until its app exits. After a longer stop, all held requests are dropped and apps don't send them again. - `SimulateUserActivity` is accepted but does nothing. Calls are logged once when a client starts sending them and once, with a count, after 5 minutes without any. - `GetActive` always returns `false`. diff --git a/xautolock-inhibit b/xautolock-inhibit index 754271a..a5476c9 100755 --- a/xautolock-inhibit +++ b/xautolock-inhibit @@ -6,13 +6,15 @@ While an inhibit is active, X screen blanking is also paused (like mpv does). import argparse import ctypes -import itertools +import json +import os import signal import subprocess import sys import time from dataclasses import dataclass from datetime import timedelta +from typing import Any from gi.repository import Gio, GLib @@ -26,6 +28,8 @@ BUS_NAME = "org.freedesktop.ScreenSaver" OBJECT_PATHS = ("/org/freedesktop/ScreenSaver", "/ScreenSaver") # Vivaldi uses the first, Zen the second EXIT_NAME_TAKEN = 2 ACTIVITY_QUIET = 300 # seconds without SimulateUserActivity that end a logged burst +STATE_FILE = "xautolock-inhibit.json" # in $XDG_RUNTIME_DIR +STATE_HEARTBEAT = 10 # seconds between state file touches while inhibits are held Cookie = tuple[str, str, str, bool] # sender, application_name, reason, counts @@ -120,12 +124,19 @@ class ActivityBurst: class Inhibitor: - def __init__(self, ignored_reasons: set[str], release_delay: int, blanking: XBlanking) -> None: + def __init__( + self, ignored_reasons: set[str], release_delay: int, blanking: XBlanking, + state_path: str | None, restore_window: int, + ) -> None: self.ignored_reasons = ignored_reasons self.blanking = blanking self.release_delay = release_delay + self.state_path = state_path + self.restore_window = restore_window + self.state_ready = False # set once this instance owns the name and may write the state file + self.heartbeat_timer = 0 self.cookies: dict[int, Cookie] = {} - self.next_cookie = itertools.count(1) + self.last_cookie = 0 self.proc_names: dict[str, str] = {} # sender -> "comm[pid]", for logging only self.activity: dict[str, ActivityBurst] = {} # sender -> SimulateUserActivity burst self.disabled = False @@ -171,20 +182,121 @@ class Inhibitor: if self.release_timer: GLib.source_remove(self.release_timer) self.release_timer = 0 + self.save(stopped=True) if self.disabled: self.unblock() + # --- state file ------------------------------------------------------- + + def start(self) -> None: + """Take over inhibits saved by a previous instance, then set xautolock to match.""" + self.restore() + self.state_ready = True + self.save() + if self.active_count(): + self.block() + else: + # Start from a known state, in case a previous instance died while disabled. + xautolock("-enable") + + def restore(self) -> None: + if not self.state_path: + log("XDG_RUNTIME_DIR is not set; inhibits will not survive a restart") + return + assert self.conn is not None + try: + with open(self.state_path) as f: + state = json.load(f) + age = time.time() - (state["stopped_at"] or os.stat(self.state_path).st_mtime) + bus_id = state["bus_id"] + last_cookie = int(state["last_cookie"]) + saved = [(int(c), s, a, r) for c, s, a, r in state["cookies"]] + except FileNotFoundError: + return + except (OSError, ValueError, KeyError, TypeError) as e: + log(f"Ignoring unreadable state file {self.state_path}: {e}") + return + if bus_id != self.conn.get_guid(): + # Unique names like :1.42 are reused by a new bus, so they would mean other clients. + if saved: + log(f"Not restoring {len(saved)} inhibit(s): saved on another session bus") + return + # Keep numbering after old cookies, so a late UnInhibit can't hit a new one. + self.last_cookie = last_cookie + if not saved: + return + if age > self.restore_window: + # An UnInhibit sent while no instance ran is lost; don't trust an old file. + log(f"Not restoring {len(saved)} inhibit(s): saved {age:.0f}s ago, " + f"over the {self.restore_window}s window") + return + for cookie, sender, app, reason in saved: + if not self.has_owner(sender): + log(f"Not restoring {cookie}: '{app}' '{reason}' ({sender} left the bus)") + continue + counts = reason not in self.ignored_reasons + self.cookies[cookie] = (sender, app, reason, counts) + note = "" if counts else " (reason ignored, does not block locking)" + log(f"Restored {cookie}: '{app}' '{reason}' from {self.proc_name(sender)}{note}") + + def save(self, stopped: bool = False) -> None: + """Write the held inhibits atomically; while any are held, touch the file regularly. + + The touches keep the mtime recent, so after a crash the next instance can tell how + long ago it happened. A clean stop records the time in `stopped_at` instead. + """ + if not self.state_ready or not self.state_path: + return + assert self.conn is not None + state = { + "bus_id": self.conn.get_guid(), + "last_cookie": self.last_cookie, + "stopped_at": time.time() if stopped else None, + "cookies": [[c, s, a, r] for c, (s, a, r, _) in self.cookies.items()], + } + tmp = f"{self.state_path}.tmp" + try: + with open(tmp, "w") as f: + json.dump(state, f) + os.replace(tmp, self.state_path) + except OSError as e: + log(f"Cannot write state file {self.state_path}: {e}") + if self.cookies and not stopped: + if not self.heartbeat_timer: + self.heartbeat_timer = GLib.timeout_add_seconds(STATE_HEARTBEAT, self.on_heartbeat) + elif self.heartbeat_timer: + GLib.source_remove(self.heartbeat_timer) + self.heartbeat_timer = 0 + + def on_heartbeat(self) -> bool: + assert self.state_path is not None + try: + os.utime(self.state_path) + except OSError as e: + log(f"Cannot touch state file {self.state_path}: {e}") + return GLib.SOURCE_CONTINUE + # --- D-Bus ------------------------------------------------------------ + def bus_call(self, method: str, name: str, reply_type: str) -> Any: + """Call an org.freedesktop.DBus method that takes a bus name; return its single result.""" + assert self.conn is not None # set in on_bus_acquired, before any method call + return self.conn.call_sync( + "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + method, GLib.Variant("(s)", (name,)), + GLib.VariantType(reply_type), Gio.DBusCallFlags.NONE, 1000, None, + ).unpack()[0] + + def has_owner(self, name: str) -> bool: + try: + return bool(self.bus_call("NameHasOwner", name, "(b)")) + except GLib.Error: + return False + def proc_name(self, sender: str) -> str: if sender not in self.proc_names: - assert self.conn is not None # set in on_bus_acquired, before any method call try: - pid = self.conn.call_sync( - "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", - "GetConnectionUnixProcessID", GLib.Variant("(s)", (sender,)), - GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, 1000, None, - ).unpack()[0] + pid = self.bus_call("GetConnectionUnixProcessID", sender, "(u)") with open(f"/proc/{pid}/comm") as f: self.proc_names[sender] = f"{f.read().strip()}[{pid}]" except (GLib.Error, OSError): @@ -192,11 +304,13 @@ class Inhibitor: return f"{sender} {self.proc_names[sender]}" def inhibit(self, sender: str, app: str, reason: str) -> int: - cookie = next(self.next_cookie) + self.last_cookie += 1 + cookie = self.last_cookie counts = reason not in self.ignored_reasons self.cookies[cookie] = (sender, app, reason, counts) note = "" if counts else " (reason ignored, does not block locking)" log(f"Inhibit {cookie}: '{app}' '{reason}' from {self.proc_name(sender)}{note}") + self.save() self.update() return cookie @@ -208,6 +322,7 @@ class Inhibitor: del self.cookies[cookie] _, app, reason, _ = entry log(f"UnInhibit {cookie}: '{app}' '{reason}'") + self.save() self.update() def simulate_activity(self, sender: str) -> None: @@ -247,6 +362,7 @@ class Inhibitor: self.end_activity(sender) self.proc_names.pop(sender, None) if gone: + self.save() self.update() def on_method_call( @@ -304,6 +420,11 @@ def main() -> None: "-d", "--release-delay", type=int, default=5, metavar="SECONDS", help="wait this long after the last release before re-enabling xautolock (default: 5)", ) + parser.add_argument( + "--restore-window", type=int, default=30, metavar="SECONDS", + help="take over inhibits from a previous instance that stopped at most this long ago " + "(default: 30; 0 disables)", + ) args = parser.parse_args() ignored: set[str] @@ -311,7 +432,11 @@ def main() -> None: ignored = set() else: ignored = set(args.ignore_reason or ["Playing audio"]) - inhibitor = Inhibitor(ignored, max(args.release_delay, 0), XBlanking()) + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") + state_path = os.path.join(runtime_dir, STATE_FILE) if runtime_dir else None + inhibitor = Inhibitor( + ignored, max(args.release_delay, 0), XBlanking(), state_path, max(args.restore_window, 0), + ) loop = GLib.MainLoop() exit_code = 0 acquired = False @@ -320,9 +445,9 @@ def main() -> None: nonlocal acquired acquired = True log(f"Owning {name}; ignored reasons: {sorted(ignored) or 'none'}") - # Start from a known state, in case a previous instance died while disabled. - # Only after owning the name, so a second instance can't undo an active inhibit. - xautolock("-enable") + # Only after owning the name, so a second instance can't undo an active inhibit + # or overwrite the state file. + inhibitor.start() def on_name_lost(conn: Gio.DBusConnection | None, name: str) -> None: nonlocal exit_code