5 Commits
Author SHA1 Message Date
Daniele Fucini 0234456af8 Log SimulateUserActivity once per burst
Steam calls it thousands of times per hour during controller play, which made up
~98% of the journal. Log the first call and a summary with the count after 5
minutes of quiet, when the client leaves, or on shutdown.
2026-10-15 23:38:52 +02:00
Daniele Fucini 51e18766a4 Update PKGBUILD 2026-10-14 13:32:58 +02:00
Daniele Fucini fa882bb0d7 Install README and bump to 0.1.1 2026-10-14 13:32:25 +02:00
Daniele Fucini af82b10d3c Add README 2026-10-13 21:22:54 +02:00
Daniele Fucini 1b0f2a2a66 Update PKGBUILD 2026-10-09 21:15:57 +02:00
3 changed files with 149 additions and 10 deletions
+8 -7
View File
@@ -3,11 +3,11 @@
# 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 pkgver=0.1.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')
url="https://git.shouldnt.work/fuxino/${pkgname}" url="https://git.shouldnt.work/fuxino/$pkgname"
license=('GPL-3.0-or-later') license=('GPL-3.0-or-later')
depends=('libx11' depends=('libx11'
'libxss' 'libxss'
@@ -15,13 +15,14 @@ depends=('libx11'
'python-gobject' 'python-gobject'
'xautolock') 'xautolock')
makedepends=('git') makedepends=('git')
source=(git+${url}?signed#tag=${pkgver}) source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514') validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('SKIP') sha256sums=('6713c706e422cea0ae346fc7bd16611dd0e32487acf4d15ec3a4db325eaaa0c7')
package() package()
{ {
cd "${srcdir}/${pkgname}" cd "$srcdir"/"$pkgname"
install -Dm755 "${pkgname}" "${pkgdir}/usr/bin/${pkgname}" install -Dm755 "$pkgname" "$pkgdir"/usr/bin/"$pkgname"
install -Dm644 "${pkgname}.service" "${pkgdir}/usr/lib/systemd/user/${pkgname}.service" install -Dm644 "$pkgname".service "$pkgdir"/usr/lib/systemd/user/"$pkgname".service
install -Dm644 README.md "$pkgdir"/usr/share/doc/"$pkgname"/README.md
} }
+93
View File
@@ -0,0 +1,93 @@
# xautolock-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).
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:
- 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.
xautolock stays your locker; xautolock-inhibit only switches it off and on.
## 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`.
- 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.
- 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.
- 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.
## Requirements
- `xautolock`, already running in the session
- `python`, `python-gobject`
- `libx11`, `libxss`
## Installation
Build and install the package with the included PKGBUILD:
```
makepkg -si
```
Then start it from your window manager's startup, after `xautolock`. For xmonad:
```haskell
spawnOnce "xautolock -time 10 -locker slock -detectsleep"
spawnOnce "systemctl --user start xautolock-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.
## Usage
```
xautolock-inhibit [-i REASON] [--count-all] [-d 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).
To change the options used by the service:
```
systemctl --user edit xautolock-inhibit.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/xautolock-inhibit --count-all
```
Every request, release and xautolock call is logged to the journal:
```
journalctl --user -u xautolock-inhibit
```
## App notes
- **Sandboxed apps** may be blocked from reaching the service. For a firejail profile that filters D-Bus (`dbus-user filter`, as the Firefox-based profiles do), allow it in the app's `.local` file, e.g. `zen-browser.local`:
```
dbus-user.talk org.freedesktop.ScreenSaver
```
- **Games**: Steam's overlay and gamemode (`inhibit_screensaver=1`) send requests on their own.
- **scrcpy** only sends a request when started with `--disable-screensaver`.
- **mpv** talks to the X server directly and doesn't need this daemon.
## 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.
- `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`.
## License
GPL-3.0-or-later. See [LICENSE](LICENSE).
+48 -3
View File
@@ -10,6 +10,9 @@ import itertools
import signal import signal
import subprocess import subprocess
import sys import sys
import time
from dataclasses import dataclass
from datetime import timedelta
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
@@ -22,6 +25,7 @@ except ImportError:
BUS_NAME = "org.freedesktop.ScreenSaver" BUS_NAME = "org.freedesktop.ScreenSaver"
OBJECT_PATHS = ("/org/freedesktop/ScreenSaver", "/ScreenSaver") # Vivaldi uses the first, Zen the second OBJECT_PATHS = ("/org/freedesktop/ScreenSaver", "/ScreenSaver") # Vivaldi uses the first, Zen the second
EXIT_NAME_TAKEN = 2 EXIT_NAME_TAKEN = 2
ACTIVITY_QUIET = 300 # seconds without SimulateUserActivity that end a logged burst
INTROSPECTION = """ INTROSPECTION = """
<node> <node>
@@ -105,6 +109,14 @@ class XBlanking:
log(f"X blanking {'paused' if on else 'resumed'}") log(f"X blanking {'paused' if on else 'resumed'}")
@dataclass
class ActivityBurst:
first: float
last: float
calls: int = 0
timer: int = 0
class Inhibitor: class Inhibitor:
def __init__(self, ignored_reasons, release_delay, blanking): def __init__(self, ignored_reasons, release_delay, blanking):
self.ignored_reasons = ignored_reasons self.ignored_reasons = ignored_reasons
@@ -113,6 +125,7 @@ class Inhibitor:
self.cookies = {} # cookie -> (sender, application_name, reason, counts) self.cookies = {} # cookie -> (sender, application_name, reason, counts)
self.next_cookie = itertools.count(1) self.next_cookie = itertools.count(1)
self.proc_names = {} # sender -> "comm[pid]", for logging only self.proc_names = {} # sender -> "comm[pid]", for logging only
self.activity: dict[str, ActivityBurst] = {} # sender -> SimulateUserActivity burst
self.disabled = False self.disabled = False
self.release_timer = 0 self.release_timer = 0
self.conn = None self.conn = None
@@ -150,7 +163,9 @@ class Inhibitor:
self.blanking.suspend(False) self.blanking.suspend(False)
self.disabled = False self.disabled = False
def shutdown(self): def shutdown(self) -> None:
for sender in list(self.activity):
self.end_activity(sender)
if self.release_timer: if self.release_timer:
GLib.source_remove(self.release_timer) GLib.source_remove(self.release_timer)
self.release_timer = 0 self.release_timer = 0
@@ -192,11 +207,41 @@ class Inhibitor:
log(f"UnInhibit {cookie}: '{app}' '{reason}'") log(f"UnInhibit {cookie}: '{app}' '{reason}'")
self.update() self.update()
def release_sender(self, sender): 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] gone = [c for c, (s, *_) in self.cookies.items() if s == sender]
for c in gone: for c in gone:
_, app, reason, _ = self.cookies.pop(c) _, app, reason, _ = self.cookies.pop(c)
log(f"Released {c}: '{app}' '{reason}' ({sender} left the bus)") log(f"Released {c}: '{app}' '{reason}' ({sender} left the bus)")
self.end_activity(sender)
self.proc_names.pop(sender, None) self.proc_names.pop(sender, None)
if gone: if gone:
self.update() self.update()
@@ -209,7 +254,7 @@ class Inhibitor:
self.uninhibit(sender, params.unpack()[0]) self.uninhibit(sender, params.unpack()[0])
invocation.return_value(None) invocation.return_value(None)
elif method == "SimulateUserActivity": elif method == "SimulateUserActivity":
log(f"SimulateUserActivity from {self.proc_name(sender)} (not implemented)") self.simulate_activity(sender)
invocation.return_value(None) invocation.return_value(None)
elif method == "Lock": elif method == "Lock":
log(f"Lock from {self.proc_name(sender)}") log(f"Lock from {self.proc_name(sender)}")