diff --git a/README.md b/README.md index 9827956..9f23c69 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,31 @@ -# xautolock-inhibit +# xidle-inhibit -Makes screen locking with `xautolock` respect the "don't lock the screen" requests that apps send, on X11 sessions without a desktop environment (for example a plain window manager like xmonad). +Makes screen locking with `xautolock` or `xss-lock` respect the "don't lock the screen" requests that apps send, on X11 sessions without a desktop environment (for example a plain window manager like xmonad). -Browsers, Steam, gamemode and other apps ask the desktop not to lock or blank the screen while a video or game is running, through the D-Bus service `org.freedesktop.ScreenSaver`. Without a desktop environment nothing provides that service, so the requests are dropped and the screen locks in the middle of a video. xautolock-inhibit provides it: +Browsers, Steam, gamemode and other apps ask the desktop not to lock or blank the screen while a video or game is running, through the D-Bus service `org.freedesktop.ScreenSaver`. Without a desktop environment nothing provides that service, so the requests are dropped and the screen locks in the middle of a video. xidle-inhibit provides it: -- While at least one request is active, it runs `xautolock -disable` and pauses X screen blanking and DPMS. -- When the last request is released, it waits a few seconds, then runs `xautolock -enable` and resumes blanking. +- While at least one request is active, it pauses X screen blanking and DPMS, and with xautolock also runs `xautolock -disable`. xss-lock locks when the X screensaver starts, so pausing it is enough. +- When the last request is released, it waits a few seconds, then resumes blanking (and runs `xautolock -enable`). -xautolock stays your locker; xautolock-inhibit only switches it off and on. +Your locker setup stays as it is; xidle-inhibit only holds it off. + +Formerly `xautolock-inhibit`, which supported only xautolock. ## How it works -- Owns `org.freedesktop.ScreenSaver` on the session bus at both `/org/freedesktop/ScreenSaver` and `/ScreenSaver` (apps differ on the path), and implements `Inhibit` and `UnInhibit`. +- Owns `org.freedesktop.ScreenSaver` on the session bus at both `/org/freedesktop/ScreenSaver` and `/ScreenSaver` (apps differ on the path), and implements `Inhibit`, `UnInhibit` and `Lock`. - Tracks requests per D-Bus client. If a client disconnects without releasing them (for example a browser crash), its requests are released. -- 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. +- Blocks and unblocks only when the number of active requests goes from 0 to 1 or back to 0. Unblocking 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. +- Keeps the requests it holds in `$XDG_RUNTIME_DIR/xidle-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. +- With xautolock, it re-enables xautolock on exit (including SIGTERM). The systemd unit also runs `xidle-inhibit --cleanup` when it stops, which does the same, as a fallback. +- `Lock` runs `xautolock -locknow`, or `loginctl lock-session` with xss-lock. - If another program (a desktop environment) already owns `org.freedesktop.ScreenSaver`, it exits with status 2 instead of taking over. ## Requirements -- `xautolock`, already running in the session +- `xautolock` or `xss-lock`, already running in the session - `python`, `python-gobject` - `libx11`, `libxss` @@ -36,42 +39,72 @@ makepkg -si Or, with aurutils, build it into a local repository with `aur build` and install it with pacman. -Then start it from your window manager's startup, after `xautolock`. For xmonad: +Then start it from your window manager's startup, after the locker. For xmonad with xautolock: ```haskell spawnOnce "xautolock -time 10 -locker slock -detectsleep" -spawnOnce "systemctl --user start xautolock-inhibit" +spawnOnce "systemctl --user start xidle-inhibit" +``` + +With xss-lock, the lock timeout is the X screensaver timeout (`xset s`): + +```haskell +spawnOnce "xset s 600" +spawnOnce "xss-lock -- slock" +spawnOnce "systemctl --user start xidle-inhibit" ``` The unit has no `[Install]` section on purpose. Enabling it at login would also start it in a desktop environment, or before X is up; starting it from the window manager limits it to that session. The user service manager needs `DISPLAY` (and `XAUTHORITY`) in its environment, which `/etc/X11/xinit/xinitrc.d/50-systemd-user.sh` sets up for `startx` sessions. +### Upgrading from xautolock-inhibit + +The package replaces `xautolock-inhibit`. After upgrading, change the `systemctl --user start` line in your window manager's startup, then switch the running daemon: + +``` +systemctl --user stop xautolock-inhibit +systemctl --user start xidle-inhibit +``` + +Requests held by the old daemon are taken over if the new one starts within `--restore-window`. + +## Configuration + +The backend is set in `~/.config/xidle-inhibit/config` (`$XDG_CONFIG_HOME`): + +``` +backend = xss-lock +``` + +Values are `xautolock` and `xss-lock`. Without the file, xidle-inhibit uses whichever of the two is installed, and xautolock if both are. Restart the service after changing it. + ## Usage ``` -xautolock-inhibit [-i REASON] [--count-all] [-d SECONDS] [--restore-window SECONDS] +xidle-inhibit [-i REASON] [--count-all] [-d SECONDS] [--restore-window SECONDS] [--cleanup] ``` - `-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). +- `-d`, `--release-delay SECONDS`: wait this long after the last release before unblocking locking (default 5). - `--restore-window SECONDS`: take over requests from a previous daemon that stopped at most this long ago (default 30; 0 disables). +- `--cleanup`: run `xautolock -enable` if xautolock is the backend, then exit. Used by the unit's `ExecStopPost`. To change the options used by the service: ``` -systemctl --user edit xautolock-inhibit.service +systemctl --user edit xidle-inhibit.service ``` ```ini [Service] ExecStart= -ExecStart=/usr/bin/xautolock-inhibit --count-all +ExecStart=/usr/bin/xidle-inhibit --count-all ``` -Every request, release and xautolock call is logged to the journal: +Every request, release and locker command is logged to the journal: ``` -journalctl --user -u xautolock-inhibit +journalctl --user -u xidle-inhibit ``` ## App notes @@ -89,6 +122,7 @@ journalctl --user -u xautolock-inhibit ## Limitations - 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. +- With xss-lock, requests block only the idle timeout: suspending, or `loginctl lock-session` from elsewhere, still locks. - `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/pyproject.toml b/pyproject.toml index 659fa47..4e6c1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [tool.ruff] line-length = 120 -extend-include = ["xautolock-inhibit"] +extend-include = ["xidle-inhibit"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP"] [tool.mypy] -files = ["xautolock-inhibit"] +files = ["xidle-inhibit"] strict = true diff --git a/xautolock-inhibit.service b/xautolock-inhibit.service deleted file mode 100644 index f96e9d0..0000000 --- a/xautolock-inhibit.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Turn org.freedesktop.ScreenSaver inhibit requests into xautolock control - -[Service] -Type=dbus -BusName=org.freedesktop.ScreenSaver -ExecStart=/usr/bin/xautolock-inhibit -ExecStopPost=-/usr/bin/xautolock -enable -Restart=on-failure -RestartPreventExitStatus=2 diff --git a/xautolock-inhibit b/xidle-inhibit similarity index 82% rename from xautolock-inhibit rename to xidle-inhibit index a5476c9..7a9b605 100755 --- a/xautolock-inhibit +++ b/xidle-inhibit @@ -1,13 +1,15 @@ #!/usr/bin/env python3 -"""Own org.freedesktop.ScreenSaver and turn inhibit requests into xautolock -disable/-enable. +"""Own org.freedesktop.ScreenSaver and block screen locking by xautolock or xss-lock while inhibits are held. -While an inhibit is active, X screen blanking is also paused (like mpv does). +While an inhibit is active, X screen blanking is paused (like mpv does), which also keeps xss-lock from +locking; with xautolock, it is also switched off with xautolock -disable. """ import argparse import ctypes import json import os +import shutil import signal import subprocess import sys @@ -19,7 +21,7 @@ from typing import Any from gi.repository import Gio, GLib try: - from gi.repository import GLibUnix # type: ignore[attr-defined] # GLib >= 2.80, not in pygobject-stubs + from gi.repository import GLibUnix # GLib >= 2.80 signal_add = GLibUnix.signal_add except ImportError: signal_add = GLib.unix_signal_add @@ -28,8 +30,11 @@ 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_FILE = "xidle-inhibit.json" # in $XDG_RUNTIME_DIR +OLD_STATE_FILE = "xautolock-inhibit.json" # before the rename (0.2); read once when upgrading STATE_HEARTBEAT = 10 # seconds between state file touches while inhibits are held +CONFIG_FILE = "xidle-inhibit/config" # in $XDG_CONFIG_HOME +BACKENDS = ("xautolock", "xss-lock") Cookie = tuple[str, str, str, bool] # sender, application_name, reason, counts @@ -68,12 +73,39 @@ def log(msg: str) -> None: print(msg, flush=True) -def xautolock(arg: str) -> None: +def run(*cmd: str) -> None: try: - subprocess.run(["xautolock", arg], check=True, timeout=10) - log(f"xautolock {arg}") + subprocess.run(cmd, check=True, timeout=10) + log(" ".join(cmd)) except (OSError, subprocess.SubprocessError) as e: - log(f"xautolock {arg} failed: {e}") + log(f"{' '.join(cmd)} failed: {e}") + + +def xautolock(arg: str) -> None: + run("xautolock", arg) + + +def read_backend() -> str: + """Return the backend set in the config file, else the installed locker (xautolock if both are).""" + config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + path = os.path.join(config_home, CONFIG_FILE) + try: + with open(path) as f: + for line in f: + key, sep, value = line.partition("=") + if not sep or key.strip() != "backend": + continue + value = value.strip().strip("\"'") + if value in BACKENDS: + return value + log(f"{path}: unknown backend '{value}' (expected {' or '.join(BACKENDS)}); detecting") + except FileNotFoundError: + pass + except OSError as e: + log(f"Cannot read {path}: {e}; detecting the backend") + if not shutil.which("xautolock") and shutil.which("xss-lock"): + return "xss-lock" + return "xautolock" class XBlanking: @@ -125,9 +157,10 @@ class ActivityBurst: class Inhibitor: def __init__( - self, ignored_reasons: set[str], release_delay: int, blanking: XBlanking, + self, backend: str, ignored_reasons: set[str], release_delay: int, blanking: XBlanking, state_path: str | None, restore_window: int, ) -> None: + self.xautolock = backend == "xautolock" # else xss-lock: pausing X blanking is enough self.ignored_reasons = ignored_reasons self.blanking = blanking self.release_delay = release_delay @@ -149,7 +182,7 @@ class Inhibitor: return sum(1 for *_, counts in self.cookies.values() if counts) def update(self) -> None: - """Run xautolock only on 0->1 and 1->0 transitions; delay re-enabling.""" + """Block or unblock only on 0->1 and 1->0 transitions; delay unblocking.""" active = self.active_count() if active and self.release_timer: GLib.source_remove(self.release_timer) @@ -167,12 +200,14 @@ class Inhibitor: return GLib.SOURCE_REMOVE def block(self) -> None: - xautolock("-disable") + if self.xautolock: + xautolock("-disable") self.blanking.suspend(True) self.disabled = True def unblock(self) -> None: - xautolock("-enable") + if self.xautolock: + xautolock("-enable") self.blanking.suspend(False) self.disabled = False @@ -189,13 +224,21 @@ class Inhibitor: # --- state file ------------------------------------------------------- def start(self) -> None: - """Take over inhibits saved by a previous instance, then set xautolock to match.""" + """Take over inhibits saved by a previous instance, then set the locker to match.""" self.restore() self.state_ready = True self.save() + if self.state_path: + old = os.path.join(os.path.dirname(self.state_path), OLD_STATE_FILE) + try: + os.remove(old) + except FileNotFoundError: + pass + except OSError as e: + log(f"Cannot remove old state file {old}: {e}") if self.active_count(): self.block() - else: + elif self.xautolock: # Start from a known state, in case a previous instance died while disabled. xautolock("-enable") @@ -204,18 +247,24 @@ class Inhibitor: log("XDG_RUNTIME_DIR is not set; inhibits will not survive a restart") return assert self.conn is not None + path = self.state_path + old = os.path.join(os.path.dirname(path), OLD_STATE_FILE) + if not os.path.exists(path) and os.path.exists(old): + path = old try: - with open(self.state_path) as f: + with open(path) as f: state = json.load(f) - age = time.time() - (state["stopped_at"] or os.stat(self.state_path).st_mtime) + age = time.time() - (state["stopped_at"] or os.stat(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}") + log(f"Ignoring unreadable state file {path}: {e}") return + if path == old: + log(f"Reading inhibits from {old} (xautolock-inhibit)") 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: @@ -380,7 +429,10 @@ class Inhibitor: invocation.return_value(None) elif method == "Lock": log(f"Lock from {self.proc_name(sender)}") - xautolock("-locknow") + if self.xautolock: + xautolock("-locknow") + else: + run("loginctl", "lock-session") invocation.return_value(None) elif method in ("GetActive", "SetActive"): invocation.return_value(GLib.Variant("(b)", (False,))) @@ -388,7 +440,7 @@ class Inhibitor: invocation.return_value(GLib.Variant("(u)", (0,))) def on_name_owner_changed( - self, conn: Gio.DBusConnection, sender: str, path: str, iface: str, signal_name: str, + self, conn: Gio.DBusConnection, sender: str | None, path: str, iface: str, signal_name: str, params: GLib.Variant, ) -> None: name, _old, new = params.unpack() @@ -418,15 +470,25 @@ def main() -> None: ) parser.add_argument( "-d", "--release-delay", type=int, default=5, metavar="SECONDS", - help="wait this long after the last release before re-enabling xautolock (default: 5)", + help="wait this long after the last release before unblocking locking (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)", ) + parser.add_argument( + "--cleanup", action="store_true", + help="re-enable xautolock if it is the backend, then exit (for the unit's ExecStopPost)", + ) args = parser.parse_args() + backend = read_backend() + if args.cleanup: + if backend == "xautolock": + xautolock("-enable") + return + ignored: set[str] if args.count_all: ignored = set() @@ -434,8 +496,11 @@ def main() -> None: ignored = set(args.ignore_reason or ["Playing audio"]) runtime_dir = os.environ.get("XDG_RUNTIME_DIR") state_path = os.path.join(runtime_dir, STATE_FILE) if runtime_dir else None + blanking = XBlanking() + if backend == "xss-lock" and not blanking.dpy: + log("Warning: with xss-lock, inhibits block locking only through X blanking, which is unavailable") inhibitor = Inhibitor( - ignored, max(args.release_delay, 0), XBlanking(), state_path, max(args.restore_window, 0), + backend, ignored, max(args.release_delay, 0), blanking, state_path, max(args.restore_window, 0), ) loop = GLib.MainLoop() exit_code = 0 @@ -444,7 +509,7 @@ def main() -> None: def on_name_acquired(conn: Gio.DBusConnection, name: str) -> None: nonlocal acquired acquired = True - log(f"Owning {name}; ignored reasons: {sorted(ignored) or 'none'}") + log(f"Owning {name}; backend: {backend}; ignored reasons: {sorted(ignored) or 'none'}") # Only after owning the name, so a second instance can't undo an active inhibit # or overwrite the state file. inhibitor.start() diff --git a/xidle-inhibit.service b/xidle-inhibit.service new file mode 100644 index 0000000..102e11d --- /dev/null +++ b/xidle-inhibit.service @@ -0,0 +1,10 @@ +[Unit] +Description=Block screen locking (xautolock or xss-lock) on org.freedesktop.ScreenSaver inhibit requests + +[Service] +Type=dbus +BusName=org.freedesktop.ScreenSaver +ExecStart=/usr/bin/xidle-inhibit +ExecStopPost=-/usr/bin/xidle-inhibit --cleanup +Restart=on-failure +RestartPreventExitStatus=2