6 Commits
Author SHA1 Message Date
Daniele Fucini 0fc1ab2f47 Add xss-lock backend and rename to xidle-inhibit
The backend comes from ~/.config/xidle-inhibit/config, else the installed locker. With xss-lock, pausing X blanking alone blocks locking and Lock uses loginctl; the unit's ExecStopPost runs --cleanup. Inhibits from the old xautolock-inhibit state file are taken over once. Also fix mypy with pygobject-stubs 2.19.
2026-10-23 19:53:42 +02:00
Daniele Fucini ef9c8b858b Uniform installation instructions in README 2026-10-20 22:01:28 +02:00
Daniele Fucini ddd1f6f40a Update PKGBUILD 2026-10-17 16:50:04 +02:00
Daniele Fucini 265966ffe9 Keep inhibits across daemon restarts
Save held inhibits to $XDG_RUNTIME_DIR/xautolock-inhibit.json and restore them on start if the previous instance stopped within --restore-window (default 30s) on the same bus and the senders are still connected. The file is touched every 10s while inhibits are held, so crashes are timed too.
2026-10-17 16:49:40 +02:00
Daniele Fucini a36e700df6 Add type hints and ruff/mypy config
Annotate all functions and check them with mypy --strict (needs
python-pygobject-stubs). pyproject.toml holds only tool config since
the PKGBUILD installs the script directly.
2026-10-16 17:21:34 +02:00
Daniele Fucini 13c1afd7b6 Update PKGBUILD 2026-10-15 23:39:11 +02:00
8 changed files with 634 additions and 383 deletions
+3
View File
@@ -1 +1,4 @@
CLAUDE.md CLAUDE.md
__pycache__/
.mypy_cache/
.ruff_cache/
+2 -2
View File
@@ -3,7 +3,7 @@
# Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com> # Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com>
pkgname=xautolock-inhibit pkgname=xautolock-inhibit
pkgver=0.1.2 pkgver=0.2
pkgrel=1 pkgrel=1
pkgdesc='Honor org.freedesktop.ScreenSaver inhibit requests by disabling xautolock' pkgdesc='Honor org.freedesktop.ScreenSaver inhibit requests by disabling xautolock'
arch=('any') arch=('any')
@@ -17,7 +17,7 @@ depends=('libx11'
makedepends=('git') makedepends=('git')
source=("git+$url?signed#tag=$pkgver") source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514') validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('6713c706e422cea0ae346fc7bd16611dd0e32487acf4d15ec3a4db325eaaa0c7') sha256sums=('c9d4bf5de7a1bd3f8ddf0cd2103e01891726877cfe12cb0f545c880b0df6f44f')
package() package()
{ {
+58 -20
View File
@@ -1,73 +1,110 @@
# 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. - 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 runs `xautolock -enable` and resumes blanking. - 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 ## 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. - 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. - 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. - 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.
- 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. - If another program (a desktop environment) already owns `org.freedesktop.ScreenSaver`, it exits with status 2 instead of taking over.
## Requirements ## Requirements
- `xautolock`, already running in the session - `xautolock` or `xss-lock`, already running in the session
- `python`, `python-gobject` - `python`, `python-gobject`
- `libx11`, `libxss` - `libx11`, `libxss`
## Installation ## Installation
Build and install the package with the included PKGBUILD: Build and install the package with the included PKGBUILD, which builds the signed release tag set in `pkgver`:
``` ```
makepkg -si makepkg -si
``` ```
Then start it from your window manager's startup, after `xautolock`. For xmonad: 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 the locker. For xmonad with xautolock:
```haskell ```haskell
spawnOnce "xautolock -time 10 -locker slock -detectsleep" 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. 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 ## Usage
``` ```
xautolock-inhibit [-i REASON] [--count-all] [-d 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`). - `-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. - `--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: To change the options used by the service:
``` ```
systemctl --user edit xautolock-inhibit.service systemctl --user edit xidle-inhibit.service
``` ```
```ini ```ini
[Service] [Service]
ExecStart= 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 ## App notes
@@ -84,7 +121,8 @@ journalctl --user -u xautolock-inhibit
## Limitations ## 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.
- 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. - `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`. - `GetActive` always returns `false`.
+10
View File
@@ -0,0 +1,10 @@
[tool.ruff]
line-length = 120
extend-include = ["xidle-inhibit"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP"]
[tool.mypy]
files = ["xidle-inhibit"]
strict = true
-351
View File
@@ -1,351 +0,0 @@
#!/usr/bin/env python3
"""Own org.freedesktop.ScreenSaver and turn inhibit requests into xautolock -disable/-enable.
While an inhibit is active, X screen blanking is also paused (like mpv does).
"""
import argparse
import ctypes
import itertools
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import timedelta
from gi.repository import Gio, GLib
try:
from gi.repository import GLibUnix # GLib >= 2.80
signal_add = GLibUnix.signal_add
except ImportError:
signal_add = GLib.unix_signal_add
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
INTROSPECTION = """
<node>
<interface name="org.freedesktop.ScreenSaver">
<method name="Inhibit">
<arg name="application_name" type="s" direction="in"/>
<arg name="reason_for_inhibit" type="s" direction="in"/>
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="UnInhibit">
<arg name="cookie" type="u" direction="in"/>
</method>
<method name="SimulateUserActivity"/>
<method name="Lock"/>
<method name="GetActive">
<arg type="b" direction="out"/>
</method>
<method name="SetActive">
<arg type="b" direction="in"/>
<arg type="b" direction="out"/>
</method>
<method name="GetActiveTime">
<arg type="u" direction="out"/>
</method>
<method name="GetSessionIdleTime">
<arg type="u" direction="out"/>
</method>
</interface>
</node>
"""
def log(msg):
print(msg, flush=True)
def xautolock(arg):
try:
subprocess.run(["xautolock", arg], check=True, timeout=10)
log(f"xautolock {arg}")
except (OSError, subprocess.SubprocessError) as e:
log(f"xautolock {arg} failed: {e}")
class XBlanking:
"""Pause X screen blanking with XScreenSaverSuspend (MIT-SCREEN-SAVER extension).
Unlike `xset s off -dpms` this changes no global setting: the X server ends the
pause by itself when this client releases it or its connection closes (exit, crash).
"""
def __init__(self):
self.dpy = None
try:
self.x11 = ctypes.CDLL("libX11.so.6")
self.xss = ctypes.CDLL("libXss.so.1")
except OSError as e:
log(f"Cannot load X11 libraries ({e}); X blanking will not be paused")
return
self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.x11.XOpenDisplay.restype = ctypes.c_void_p
self.x11.XFlush.argtypes = [ctypes.c_void_p]
self.xss.XScreenSaverQueryExtension.argtypes = [
ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]
self.xss.XScreenSaverSuspend.argtypes = [ctypes.c_void_p, ctypes.c_int]
dpy = self.x11.XOpenDisplay(None)
if not dpy:
log("Cannot open the X display; X blanking will not be paused")
return
event_base, error_base = ctypes.c_int(), ctypes.c_int()
if not self.xss.XScreenSaverQueryExtension(dpy, ctypes.byref(event_base), ctypes.byref(error_base)):
log("X server lacks the MIT-SCREEN-SAVER extension; X blanking will not be paused")
return
self.dpy = dpy
def suspend(self, on):
if self.dpy:
self.xss.XScreenSaverSuspend(self.dpy, int(on))
self.x11.XFlush(self.dpy)
log(f"X blanking {'paused' if on else 'resumed'}")
@dataclass
class ActivityBurst:
first: float
last: float
calls: int = 0
timer: int = 0
class Inhibitor:
def __init__(self, ignored_reasons, release_delay, blanking):
self.ignored_reasons = ignored_reasons
self.blanking = blanking
self.release_delay = release_delay
self.cookies = {} # cookie -> (sender, application_name, reason, counts)
self.next_cookie = itertools.count(1)
self.proc_names = {} # sender -> "comm[pid]", for logging only
self.activity: dict[str, ActivityBurst] = {} # sender -> SimulateUserActivity burst
self.disabled = False
self.release_timer = 0
self.conn = None
# --- lock state -------------------------------------------------------
def active_count(self):
return sum(1 for *_, counts in self.cookies.values() if counts)
def update(self):
"""Run xautolock only on 0->1 and 1->0 transitions; delay re-enabling."""
active = self.active_count()
if active and self.release_timer:
GLib.source_remove(self.release_timer)
self.release_timer = 0
if active and not self.disabled:
self.block()
elif not active and self.disabled and not self.release_timer:
# Browsers send quick Inhibit/UnInhibit bursts when a video starts.
self.release_timer = GLib.timeout_add_seconds(self.release_delay, self.on_release_timeout)
def on_release_timeout(self):
self.release_timer = 0
if not self.active_count() and self.disabled:
self.unblock()
return GLib.SOURCE_REMOVE
def block(self):
xautolock("-disable")
self.blanking.suspend(True)
self.disabled = True
def unblock(self):
xautolock("-enable")
self.blanking.suspend(False)
self.disabled = False
def shutdown(self) -> None:
for sender in list(self.activity):
self.end_activity(sender)
if self.release_timer:
GLib.source_remove(self.release_timer)
self.release_timer = 0
if self.disabled:
self.unblock()
# --- D-Bus ------------------------------------------------------------
def proc_name(self, sender):
if sender not in self.proc_names:
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]
with open(f"/proc/{pid}/comm") as f:
self.proc_names[sender] = f"{f.read().strip()}[{pid}]"
except (GLib.Error, OSError):
self.proc_names[sender] = "?"
return f"{sender} {self.proc_names[sender]}"
def inhibit(self, sender, app, reason):
cookie = next(self.next_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.update()
return cookie
def uninhibit(self, sender, cookie):
entry = self.cookies.get(cookie)
if entry is None or entry[0] != sender:
log(f"UnInhibit {cookie} from {self.proc_name(sender)}: unknown cookie, ignored")
return
del self.cookies[cookie]
_, app, reason, _ = entry
log(f"UnInhibit {cookie}: '{app}' '{reason}'")
self.update()
def simulate_activity(self, sender: str) -> None:
"""Log a sender's calls once per burst: Steam sends thousands per hour."""
now = time.monotonic()
burst = self.activity.get(sender)
if burst is None:
log(f"SimulateUserActivity from {self.proc_name(sender)} (not implemented)")
burst = self.activity[sender] = ActivityBurst(first=now, last=now)
else:
GLib.source_remove(burst.timer)
burst.calls += 1
burst.last = now
burst.timer = GLib.timeout_add_seconds(ACTIVITY_QUIET, self.on_activity_quiet, sender)
def on_activity_quiet(self, sender: str) -> bool:
self.activity[sender].timer = 0
self.end_activity(sender)
return GLib.SOURCE_REMOVE
def end_activity(self, sender: str) -> None:
burst = self.activity.pop(sender, None)
if burst is None:
return
if burst.timer:
GLib.source_remove(burst.timer)
if burst.calls == 1:
return
span = timedelta(seconds=round(burst.last - burst.first))
log(f"SimulateUserActivity from {self.proc_name(sender)}: {burst.calls} calls over {span}")
def release_sender(self, sender: str) -> None:
gone = [c for c, (s, *_) in self.cookies.items() if s == sender]
for c in gone:
_, app, reason, _ = self.cookies.pop(c)
log(f"Released {c}: '{app}' '{reason}' ({sender} left the bus)")
self.end_activity(sender)
self.proc_names.pop(sender, None)
if gone:
self.update()
def on_method_call(self, conn, sender, path, iface, method, params, invocation):
if method == "Inhibit":
app, reason = params.unpack()
invocation.return_value(GLib.Variant("(u)", (self.inhibit(sender, app, reason),)))
elif method == "UnInhibit":
self.uninhibit(sender, params.unpack()[0])
invocation.return_value(None)
elif method == "SimulateUserActivity":
self.simulate_activity(sender)
invocation.return_value(None)
elif method == "Lock":
log(f"Lock from {self.proc_name(sender)}")
xautolock("-locknow")
invocation.return_value(None)
elif method in ("GetActive", "SetActive"):
invocation.return_value(GLib.Variant("(b)", (False,)))
elif method in ("GetActiveTime", "GetSessionIdleTime"):
invocation.return_value(GLib.Variant("(u)", (0,)))
def on_name_owner_changed(self, conn, sender, path, iface, signal_name, params):
name, _old, new = params.unpack()
if not new and name.startswith(":"):
self.release_sender(name)
def on_bus_acquired(self, conn, name):
self.conn = conn
iface = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION).interfaces[0]
for path in OBJECT_PATHS:
conn.register_object(path, iface, self.on_method_call, None, None)
conn.signal_subscribe(
"org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
"/org/freedesktop/DBus", None, Gio.DBusSignalFlags.NONE, self.on_name_owner_changed,
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-i", "--ignore-reason", action="append", metavar="REASON",
help="inhibit reason that does not block locking (repeatable; default: 'Playing audio')",
)
parser.add_argument(
"--count-all", action="store_true",
help="let every inhibit block locking, ignoring no reasons",
)
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)",
)
args = parser.parse_args()
if args.count_all:
ignored = set()
else:
ignored = set(args.ignore_reason or ["Playing audio"])
inhibitor = Inhibitor(ignored, max(args.release_delay, 0), XBlanking())
loop = GLib.MainLoop()
exit_code = 0
acquired = False
def on_name_acquired(conn, name):
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")
def on_name_lost(conn, name):
nonlocal exit_code
if conn is None:
log("Could not connect to the session bus")
exit_code = 1
elif acquired:
log(f"Lost {name} (bus connection closed?)")
exit_code = 1
else:
log(f"{name} is owned by another process (a desktop environment?); exiting")
exit_code = EXIT_NAME_TAKEN
loop.quit()
def on_signal():
log("Signal received, exiting")
loop.quit()
return GLib.SOURCE_REMOVE
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal_add(GLib.PRIORITY_HIGH, sig, on_signal)
owner_id = Gio.bus_own_name(
Gio.BusType.SESSION, BUS_NAME, Gio.BusNameOwnerFlags.DO_NOT_QUEUE,
inhibitor.on_bus_acquired, on_name_acquired, on_name_lost,
)
try:
loop.run()
finally:
inhibitor.shutdown()
Gio.bus_unown_name(owner_id)
sys.exit(exit_code)
if __name__ == "__main__":
main()
-10
View File
@@ -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
Executable
+551
View File
@@ -0,0 +1,551 @@
#!/usr/bin/env python3
"""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 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
import time
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from gi.repository import Gio, GLib
try:
from gi.repository import GLibUnix # GLib >= 2.80
signal_add = GLibUnix.signal_add
except ImportError:
signal_add = GLib.unix_signal_add
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 = "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
INTROSPECTION = """
<node>
<interface name="org.freedesktop.ScreenSaver">
<method name="Inhibit">
<arg name="application_name" type="s" direction="in"/>
<arg name="reason_for_inhibit" type="s" direction="in"/>
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="UnInhibit">
<arg name="cookie" type="u" direction="in"/>
</method>
<method name="SimulateUserActivity"/>
<method name="Lock"/>
<method name="GetActive">
<arg type="b" direction="out"/>
</method>
<method name="SetActive">
<arg type="b" direction="in"/>
<arg type="b" direction="out"/>
</method>
<method name="GetActiveTime">
<arg type="u" direction="out"/>
</method>
<method name="GetSessionIdleTime">
<arg type="u" direction="out"/>
</method>
</interface>
</node>
"""
def log(msg: str) -> None:
print(msg, flush=True)
def run(*cmd: str) -> None:
try:
subprocess.run(cmd, check=True, timeout=10)
log(" ".join(cmd))
except (OSError, subprocess.SubprocessError) as 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:
"""Pause X screen blanking with XScreenSaverSuspend (MIT-SCREEN-SAVER extension).
Unlike `xset s off -dpms` this changes no global setting: the X server ends the
pause by itself when this client releases it or its connection closes (exit, crash).
"""
def __init__(self) -> None:
self.dpy: int | None = None
try:
self.x11 = ctypes.CDLL("libX11.so.6")
self.xss = ctypes.CDLL("libXss.so.1")
except OSError as e:
log(f"Cannot load X11 libraries ({e}); X blanking will not be paused")
return
self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.x11.XOpenDisplay.restype = ctypes.c_void_p
self.x11.XFlush.argtypes = [ctypes.c_void_p]
self.xss.XScreenSaverQueryExtension.argtypes = [
ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]
self.xss.XScreenSaverSuspend.argtypes = [ctypes.c_void_p, ctypes.c_int]
dpy = self.x11.XOpenDisplay(None)
if not dpy:
log("Cannot open the X display; X blanking will not be paused")
return
event_base, error_base = ctypes.c_int(), ctypes.c_int()
if not self.xss.XScreenSaverQueryExtension(dpy, ctypes.byref(event_base), ctypes.byref(error_base)):
log("X server lacks the MIT-SCREEN-SAVER extension; X blanking will not be paused")
return
self.dpy = dpy
def suspend(self, on: bool) -> None:
if self.dpy:
self.xss.XScreenSaverSuspend(self.dpy, int(on))
self.x11.XFlush(self.dpy)
log(f"X blanking {'paused' if on else 'resumed'}")
@dataclass
class ActivityBurst:
first: float
last: float
calls: int = 0
timer: int = 0
class Inhibitor:
def __init__(
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
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.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
self.release_timer = 0
self.conn: Gio.DBusConnection | None = None
# --- lock state -------------------------------------------------------
def active_count(self) -> int:
return sum(1 for *_, counts in self.cookies.values() if counts)
def update(self) -> None:
"""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)
self.release_timer = 0
if active and not self.disabled:
self.block()
elif not active and self.disabled and not self.release_timer:
# Browsers send quick Inhibit/UnInhibit bursts when a video starts.
self.release_timer = GLib.timeout_add_seconds(self.release_delay, self.on_release_timeout)
def on_release_timeout(self) -> bool:
self.release_timer = 0
if not self.active_count() and self.disabled:
self.unblock()
return GLib.SOURCE_REMOVE
def block(self) -> None:
if self.xautolock:
xautolock("-disable")
self.blanking.suspend(True)
self.disabled = True
def unblock(self) -> None:
if self.xautolock:
xautolock("-enable")
self.blanking.suspend(False)
self.disabled = False
def shutdown(self) -> None:
for sender in list(self.activity):
self.end_activity(sender)
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 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()
elif self.xautolock:
# 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
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(path) as f:
state = json.load(f)
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 {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:
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:
try:
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):
self.proc_names[sender] = "?"
return f"{sender} {self.proc_names[sender]}"
def inhibit(self, sender: str, app: str, reason: str) -> int:
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
def uninhibit(self, sender: str, cookie: int) -> None:
entry = self.cookies.get(cookie)
if entry is None or entry[0] != sender:
log(f"UnInhibit {cookie} from {self.proc_name(sender)}: unknown cookie, ignored")
return
del self.cookies[cookie]
_, app, reason, _ = entry
log(f"UnInhibit {cookie}: '{app}' '{reason}'")
self.save()
self.update()
def simulate_activity(self, sender: str) -> None:
"""Log a sender's calls once per burst: Steam sends thousands per hour."""
now = time.monotonic()
burst = self.activity.get(sender)
if burst is None:
log(f"SimulateUserActivity from {self.proc_name(sender)} (not implemented)")
burst = self.activity[sender] = ActivityBurst(first=now, last=now)
else:
GLib.source_remove(burst.timer)
burst.calls += 1
burst.last = now
burst.timer = GLib.timeout_add_seconds(ACTIVITY_QUIET, self.on_activity_quiet, sender)
def on_activity_quiet(self, sender: str) -> bool:
self.activity[sender].timer = 0
self.end_activity(sender)
return GLib.SOURCE_REMOVE
def end_activity(self, sender: str) -> None:
burst = self.activity.pop(sender, None)
if burst is None:
return
if burst.timer:
GLib.source_remove(burst.timer)
if burst.calls == 1:
return
span = timedelta(seconds=round(burst.last - burst.first))
log(f"SimulateUserActivity from {self.proc_name(sender)}: {burst.calls} calls over {span}")
def release_sender(self, sender: str) -> None:
gone = [c for c, (s, *_) in self.cookies.items() if s == sender]
for c in gone:
_, app, reason, _ = self.cookies.pop(c)
log(f"Released {c}: '{app}' '{reason}' ({sender} left the bus)")
self.end_activity(sender)
self.proc_names.pop(sender, None)
if gone:
self.save()
self.update()
def on_method_call(
self, conn: Gio.DBusConnection, sender: str, path: str, iface: str, method: str,
params: GLib.Variant, invocation: Gio.DBusMethodInvocation,
) -> None:
if method == "Inhibit":
app, reason = params.unpack()
invocation.return_value(GLib.Variant("(u)", (self.inhibit(sender, app, reason),)))
elif method == "UnInhibit":
self.uninhibit(sender, params.unpack()[0])
invocation.return_value(None)
elif method == "SimulateUserActivity":
self.simulate_activity(sender)
invocation.return_value(None)
elif method == "Lock":
log(f"Lock from {self.proc_name(sender)}")
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,)))
elif method in ("GetActiveTime", "GetSessionIdleTime"):
invocation.return_value(GLib.Variant("(u)", (0,)))
def on_name_owner_changed(
self, conn: Gio.DBusConnection, sender: str | None, path: str, iface: str, signal_name: str,
params: GLib.Variant,
) -> None:
name, _old, new = params.unpack()
if not new and name.startswith(":"):
self.release_sender(name)
def on_bus_acquired(self, conn: Gio.DBusConnection, name: str) -> None:
self.conn = conn
iface = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION).interfaces[0]
for path in OBJECT_PATHS:
conn.register_object(path, iface, self.on_method_call, None, None)
conn.signal_subscribe(
"org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
"/org/freedesktop/DBus", None, Gio.DBusSignalFlags.NONE, self.on_name_owner_changed,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-i", "--ignore-reason", action="append", metavar="REASON",
help="inhibit reason that does not block locking (repeatable; default: 'Playing audio')",
)
parser.add_argument(
"--count-all", action="store_true",
help="let every inhibit block locking, ignoring no reasons",
)
parser.add_argument(
"-d", "--release-delay", type=int, default=5, metavar="SECONDS",
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()
else:
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(
backend, ignored, max(args.release_delay, 0), blanking, state_path, max(args.restore_window, 0),
)
loop = GLib.MainLoop()
exit_code = 0
acquired = False
def on_name_acquired(conn: Gio.DBusConnection, name: str) -> None:
nonlocal acquired
acquired = True
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()
def on_name_lost(conn: Gio.DBusConnection | None, name: str) -> None:
nonlocal exit_code
if conn is None:
log("Could not connect to the session bus")
exit_code = 1
elif acquired:
log(f"Lost {name} (bus connection closed?)")
exit_code = 1
else:
log(f"{name} is owned by another process (a desktop environment?); exiting")
exit_code = EXIT_NAME_TAKEN
loop.quit()
def on_signal() -> bool:
log("Signal received, exiting")
loop.quit()
return GLib.SOURCE_REMOVE
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal_add(GLib.PRIORITY_HIGH, sig, on_signal)
owner_id = Gio.bus_own_name(
Gio.BusType.SESSION, BUS_NAME, Gio.BusNameOwnerFlags.DO_NOT_QUEUE,
inhibitor.on_bus_acquired, on_name_acquired, on_name_lost,
)
try:
loop.run()
finally:
inhibitor.shutdown()
Gio.bus_unown_name(owner_id)
sys.exit(exit_code)
if __name__ == "__main__":
main()
+10
View File
@@ -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