5 Commits
Author SHA1 Message Date
Daniele Fucini be4b835598 Drop reading of the pre-rename xautolock-inhibit.json state file 2026-11-09 21:05:25 +01:00
Daniele Fucini 02817be364 Add end-to-end tests for the daemon
Each test runs xidle-inhibit on a private dbus-daemon with fake lockers on PATH,
covering lock blocking, cookie handling, restoring after restarts and cleanup.
2026-11-08 14:51:13 +01:00
Daniele Fucini 1452059ca5 Update PKGBUILD 2026-10-23 21:37:00 +02:00
Daniele Fucini 441b86448e Add xidle-dim notifier to dim the screen before xss-lock locks
Fades a black window in over the screen through the compositor, so it works with redshift (no gamma
changes); does nothing without a compositor. README shows it in the xss-lock setup.
2026-10-23 21:36:18 +02:00
Daniele Fucini 6548bbb232 Update PKGBUILD 2026-10-23 19:56:14 +02:00
7 changed files with 587 additions and 25 deletions
+1
View File
@@ -2,3 +2,4 @@ CLAUDE.md
__pycache__/ __pycache__/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.pytest_cache/
+10 -6
View File
@@ -2,27 +2,31 @@
# Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com> # Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com>
pkgname=xautolock-inhibit pkgname=xidle-inhibit
pkgver=0.2 pkgver=0.4
pkgrel=1 pkgrel=1
pkgdesc='Honor org.freedesktop.ScreenSaver inhibit requests by disabling xautolock' pkgdesc='Honor org.freedesktop.ScreenSaver inhibit requests by blocking xautolock or xss-lock'
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'
'python' 'python'
'python-gobject' 'python-gobject')
'xautolock') optdepends=('xautolock: xautolock backend'
'xss-lock: xss-lock backend')
makedepends=('git') makedepends=('git')
conflicts=('xautolock-inhibit')
replaces=('xautolock-inhibit')
source=("git+$url?signed#tag=$pkgver") source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514') validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('c9d4bf5de7a1bd3f8ddf0cd2103e01891726877cfe12cb0f545c880b0df6f44f') sha256sums=('fcc12e899f80b596d854049704b1b2c0293bf73b61f837b26660622044a7b877')
package() package()
{ {
cd "$srcdir"/"$pkgname" cd "$srcdir"/"$pkgname"
install -Dm755 "$pkgname" "$pkgdir"/usr/bin/"$pkgname" install -Dm755 "$pkgname" "$pkgdir"/usr/bin/"$pkgname"
install -Dm755 xidle-dim "$pkgdir"/usr/bin/xidle-dim
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 install -Dm644 README.md "$pkgdir"/usr/share/doc/"$pkgname"/README.md
} }
+19 -3
View File
@@ -28,6 +28,7 @@ Formerly `xautolock-inhibit`, which supported only xautolock.
- `xautolock` or `xss-lock`, already running in the session - `xautolock` or `xss-lock`, already running in the session
- `python`, `python-gobject` - `python`, `python-gobject`
- `libx11`, `libxss` - `libx11`, `libxss`
- For `xidle-dim`: a compositor, such as picom
## Installation ## Installation
@@ -46,14 +47,16 @@ spawnOnce "xautolock -time 10 -locker slock -detectsleep"
spawnOnce "systemctl --user start xidle-inhibit" spawnOnce "systemctl --user start xidle-inhibit"
``` ```
With xss-lock, the lock timeout is the X screensaver timeout (`xset s`): With xss-lock, the lock timeout is the X screensaver timeout (`xset s TIMEOUT CYCLE`). This dims the screen with `xidle-dim` at 9:50 and locks at 10:00:
```haskell ```haskell
spawnOnce "xset s 600" spawnOnce "xset s 590 10"
spawnOnce "xss-lock -- slock" spawnOnce "xss-lock -n xidle-dim -- slock"
spawnOnce "systemctl --user start xidle-inhibit" spawnOnce "systemctl --user start xidle-inhibit"
``` ```
Without dimming, use `xset s 600 0` and `xss-lock -- slock`. Start xss-lock from the window manager, not from a terminal: terminals like kitty run commands outside the login session, and xss-lock then ignores `loginctl lock-session`.
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 ### Upgrading from xautolock-inhibit
@@ -107,6 +110,19 @@ Every request, release and locker command is logged to the journal:
journalctl --user -u xidle-inhibit journalctl --user -u xidle-inhibit
``` ```
### Dimming before the lock
`xidle-dim` is an xss-lock notifier (`-n`). It fades a black window over the screen until xss-lock stops it, on user activity or when the locker starts. It needs a compositor such as picom and does nothing without one. Because it doesn't change gamma, it works alongside redshift.
```
xidle-dim [--opacity 0-1] [--fade SECONDS]
```
- `--opacity`: final darkness (default 0.6).
- `--fade`: fade-in time (default 1.5).
Options go inside the notifier command: `xss-lock -n "xidle-dim --opacity 0.8" -- slock`.
## App notes ## 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`: - **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`:
+6 -2
View File
@@ -1,10 +1,14 @@
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
extend-include = ["xidle-inhibit"] extend-include = ["xidle-inhibit", "xidle-dim"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP"] select = ["E", "F", "W", "I", "B", "UP"]
[tool.mypy] [tool.mypy]
files = ["xidle-inhibit"] # Scripts both map to __main__, so check xidle-dim separately: mypy xidle-dim
files = ["xidle-inhibit", "tests"]
strict = true strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
+432
View File
@@ -0,0 +1,432 @@
"""End-to-end tests: run xidle-inhibit on a private D-Bus bus with fake lockers on PATH."""
import json
import os
import signal
import subprocess
import sys
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import pytest
from gi.repository import Gio, GLib
# XIDLE_INHIBIT tests another copy, e.g. /usr/bin/xidle-inhibit.
SCRIPT = Path(os.environ.get("XIDLE_INHIBIT") or Path(__file__).resolve().parent.parent / "xidle-inhibit")
BUS_NAME = "org.freedesktop.ScreenSaver"
OBJECT_PATH = "/org/freedesktop/ScreenSaver"
STATE_FILE = "xidle-inhibit.json"
TIMEOUT = 5.0
DELAY = ["--release-delay", "1"]
SETTLE = 1.5 # longer than DELAY, to check that nothing happens
ENABLE, DISABLE = "xautolock -enable", "xautolock -disable"
# No <servicedir>: nothing is auto-started on this bus.
BUS_CONFIG = """<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<type>session</type>
<listen>unix:dir={dir}</listen>
<auth>EXTERNAL</auth>
<policy context="default">
<allow send_destination="*" eavesdrop="true"/>
<allow eavesdrop="true"/>
<allow own="*"/>
</policy>
</busconfig>
"""
FAKE_TOOL = '#!/bin/sh\necho "${0##*/} $*" >> "$FAKE_LOG"\n'
def wait_for(cond: Callable[[], bool], what: str, timeout: float = TIMEOUT) -> None:
deadline = time.monotonic() + timeout
while not cond():
if time.monotonic() > deadline:
raise AssertionError(f"timed out waiting for {what}")
time.sleep(0.05)
class Bus:
def __init__(self, root: Path) -> None:
sock_dir = root / "sock"
sock_dir.mkdir(parents=True)
config = root / "bus.conf"
config.write_text(BUS_CONFIG.format(dir=sock_dir))
self.proc = subprocess.Popen(
["dbus-daemon", f"--config-file={config}", "--nofork", "--print-address=1"],
stdout=subprocess.PIPE, text=True,
)
assert self.proc.stdout is not None
self.address = self.proc.stdout.readline().strip()
assert self.address, "dbus-daemon printed no address"
def connect(self) -> Gio.DBusConnection:
flags = Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION
return Gio.DBusConnection.new_for_address_sync(self.address, flags, None, None)
def stop(self) -> None:
self.proc.terminate()
self.proc.wait(TIMEOUT)
class Client:
"""One bus connection, so one D-Bus sender."""
def __init__(self, bus: Bus) -> None:
self.conn = bus.connect()
name = self.conn.get_unique_name()
assert name is not None
self.name = name
def call(
self, method: str, args: GLib.Variant | None = None, reply: str | None = None,
path: str = OBJECT_PATH, dest: str = BUS_NAME, iface: str = BUS_NAME,
) -> Any:
result = self.conn.call_sync(
dest, path, iface, method, args, GLib.VariantType(reply) if reply else None,
Gio.DBusCallFlags.NO_AUTO_START, int(TIMEOUT * 1000), None,
)
return result.unpack()
def inhibit(self, reason: str = "Playing video", path: str = OBJECT_PATH) -> int:
return int(self.call("Inhibit", GLib.Variant("(ss)", ("test-app", reason)), "(u)", path)[0])
def uninhibit(self, cookie: int, path: str = OBJECT_PATH) -> None:
self.call("UnInhibit", GLib.Variant("(u)", (cookie,)), path=path)
def has_owner(self, name: str) -> bool:
return bool(self.call(
"NameHasOwner", GLib.Variant("(s)", (name,)), "(b)",
"/org/freedesktop/DBus", "org.freedesktop.DBus", "org.freedesktop.DBus",
)[0])
def close(self) -> None:
if not self.conn.is_closed():
self.conn.close_sync(None)
class Sandbox:
def __init__(self, root: Path) -> None:
self.root = root
self.bin = root / "bin"
self.runtime = root / "run"
self.config = root / "config"
for d in (self.bin, self.runtime, self.config / "xidle-inhibit"):
d.mkdir(parents=True)
for tool in ("xautolock", "xss-lock", "loginctl"):
path = self.bin / tool
path.write_text(FAKE_TOOL)
path.chmod(0o755)
self.fake_log = root / "fake.log"
self.daemon_log = root / "daemon.log"
self.state_file = self.runtime / STATE_FILE
self.buses: list[Bus] = []
self.clients: list[Client] = []
self.daemons: list[subprocess.Popen[bytes]] = []
self.bus = self.new_bus()
self.set_backend("xautolock")
def new_bus(self) -> Bus:
bus = Bus(self.root / f"bus{len(self.buses)}")
self.buses.append(bus)
return bus
def client(self, bus: Bus | None = None) -> Client:
client = Client(bus or self.bus)
self.clients.append(client)
return client
def set_backend(self, backend: str | None) -> None:
config = self.config / "xidle-inhibit" / "config"
if backend is None:
config.unlink(missing_ok=True)
else:
config.write_text(f"backend = {backend}\n")
def environ(self, bus: Bus) -> dict[str, str]:
env = dict(os.environ)
for var in ("DISPLAY", "WAYLAND_DISPLAY"):
env.pop(var, None)
env.update(
PATH=str(self.bin), DBUS_SESSION_BUS_ADDRESS=bus.address, FAKE_LOG=str(self.fake_log),
XDG_RUNTIME_DIR=str(self.runtime), XDG_CONFIG_HOME=str(self.config),
)
return env
def start(self, *args: str, bus: Bus | None = None, wait: bool = True) -> subprocess.Popen[bytes]:
"""Start the daemon; with wait, return once it has taken the name and set the locker."""
bus = bus or self.bus
with open(self.daemon_log, "ab") as log:
proc = subprocess.Popen(
[sys.executable, str(SCRIPT), *args], env=self.environ(bus), stdout=log, stderr=subprocess.STDOUT,
)
self.daemons.append(proc)
if wait:
probe = Client(bus)
try:
# Method calls are handled after start() returns, so a reply means startup is done.
def answers() -> bool:
assert proc.poll() is None, f"daemon exited:\n{self.log()}"
try:
probe.call("GetActive", reply="(b)")
return True
except GLib.Error:
return False
wait_for(answers, "the daemon to own the name")
finally:
probe.close()
return proc
def stop(self, sig: int = signal.SIGTERM) -> int:
proc = self.daemons[-1]
proc.send_signal(sig)
return proc.wait(TIMEOUT)
def calls(self) -> list[str]:
try:
return self.fake_log.read_text().splitlines()
except FileNotFoundError:
return []
def wait_calls(self, expected: list[str]) -> None:
wait_for(lambda: self.calls() == expected, f"calls {expected}, got {self.calls()}")
def log(self) -> str:
return self.daemon_log.read_text()
def close(self) -> None:
for proc in self.daemons:
if proc.poll() is None:
proc.kill()
proc.wait()
for client in self.clients:
client.close()
for bus in self.buses:
bus.stop()
@pytest.fixture
def sb(tmp_path: Path) -> Iterator[Sandbox]:
sandbox = Sandbox(tmp_path)
yield sandbox
sandbox.close()
# --- blocking and unblocking ---------------------------------------------------
def test_blocks_once_and_unblocks_after_last_release(sb: Sandbox) -> None:
sb.start(*DELAY)
assert sb.calls() == [ENABLE] # known state at startup
a = sb.client()
first, second = a.inhibit(), a.inhibit()
assert second == first + 1
assert sb.calls() == [ENABLE, DISABLE]
a.uninhibit(first)
time.sleep(SETTLE)
assert sb.calls() == [ENABLE, DISABLE]
a.uninhibit(second)
sb.wait_calls([ENABLE, DISABLE, ENABLE])
def test_burst_within_release_delay_stays_blocked(sb: Sandbox) -> None:
sb.start(*DELAY)
a = sb.client()
a.uninhibit(a.inhibit())
a.inhibit()
time.sleep(SETTLE)
assert sb.calls() == [ENABLE, DISABLE]
def test_both_object_paths_share_cookies(sb: Sandbox) -> None:
sb.start(*DELAY)
a = sb.client()
cookie = a.inhibit(path="/ScreenSaver")
assert sb.calls() == [ENABLE, DISABLE]
a.uninhibit(cookie, path=OBJECT_PATH)
sb.wait_calls([ENABLE, DISABLE, ENABLE])
def test_ignored_reason_does_not_block(sb: Sandbox) -> None:
sb.start(*DELAY)
sb.client().inhibit(reason="Playing audio")
assert sb.calls() == [ENABLE]
assert "reason ignored" in sb.log()
def test_count_all_blocks_on_any_reason(sb: Sandbox) -> None:
sb.start(*DELAY, "--count-all")
sb.client().inhibit(reason="Playing audio")
assert sb.calls() == [ENABLE, DISABLE]
def test_uninhibit_from_another_sender_is_ignored(sb: Sandbox) -> None:
sb.start(*DELAY)
a, b = sb.client(), sb.client()
b.uninhibit(a.inhibit())
time.sleep(SETTLE)
assert sb.calls() == [ENABLE, DISABLE]
assert "unknown cookie, ignored" in sb.log()
def test_sender_leaving_the_bus_releases_its_inhibits(sb: Sandbox) -> None:
sb.start(*DELAY)
a, b = sb.client(), sb.client()
a.inhibit()
a.inhibit()
b.inhibit(reason="Playing audio") # another sender's cookie stays
a.close()
sb.wait_calls([ENABLE, DISABLE, ENABLE])
assert sb.log().count("left the bus") == 2
def test_xss_lock_backend_runs_no_locker_commands(sb: Sandbox) -> None:
sb.set_backend("xss-lock")
sb.start(*DELAY)
a = sb.client()
a.uninhibit(a.inhibit())
time.sleep(SETTLE)
assert sb.calls() == []
assert sb.stop() == 0
assert sb.calls() == []
@pytest.mark.parametrize(("backend", "command"), [
("xautolock", "xautolock -locknow"),
("xss-lock", "loginctl lock-session"),
])
def test_lock_and_stub_methods(sb: Sandbox, backend: str, command: str) -> None:
sb.set_backend(backend)
sb.start()
a = sb.client()
before = sb.calls()
a.call("Lock")
assert sb.calls() == [*before, command]
assert a.call("GetActive", reply="(b)") == (False,)
assert a.call("GetSessionIdleTime", reply="(u)") == (0,)
def test_stop_unblocks(sb: Sandbox) -> None:
sb.start(*DELAY)
sb.client().inhibit()
assert sb.stop() == 0
assert sb.calls() == [ENABLE, DISABLE, ENABLE]
# --- restoring after a restart -------------------------------------------------
def test_restart_restores_inhibits(sb: Sandbox) -> None:
sb.start(*DELAY)
a = sb.client()
old = a.inhibit()
assert sb.stop() == 0
sb.start(*DELAY)
assert sb.calls() == [ENABLE, DISABLE, ENABLE, DISABLE]
new = a.inhibit()
assert new == old + 1 # numbering continues, so a late UnInhibit can't hit a new cookie
a.uninhibit(old)
a.uninhibit(new)
sb.wait_calls([ENABLE, DISABLE, ENABLE, DISABLE, ENABLE])
def test_restore_skips_senders_that_left(sb: Sandbox) -> None:
sb.start()
a, probe = sb.client(), sb.client()
a.inhibit()
sb.stop()
a.close()
wait_for(lambda: not probe.has_owner(a.name), "the client to leave the bus")
sb.start()
assert sb.calls() == [ENABLE, DISABLE, ENABLE, ENABLE]
assert f"({a.name} left the bus)" in sb.log()
def test_no_restore_outside_window(sb: Sandbox) -> None:
sb.start()
sb.client().inhibit()
sb.stop()
sb.start("--restore-window", "0")
assert sb.calls()[-1] == ENABLE
assert "over the 0s window" in sb.log()
def test_no_restore_from_another_bus(sb: Sandbox) -> None:
sb.start()
sb.client().inhibit()
sb.stop()
other = sb.new_bus()
sb.start(bus=other)
assert sb.calls()[-1] == ENABLE
assert "saved on another session bus" in sb.log()
assert sb.client(other).inhibit() == 1 # cookie numbering is per bus
def test_crash_restores_by_mtime(sb: Sandbox) -> None:
sb.start()
sb.client().inhibit()
sb.stop(signal.SIGKILL)
assert json.loads(sb.state_file.read_text())["stopped_at"] is None
sb.start()
assert sb.calls() == [ENABLE, DISABLE, DISABLE]
def test_old_crash_is_not_restored(sb: Sandbox) -> None:
sb.start()
sb.client().inhibit()
sb.stop(signal.SIGKILL)
past = time.time() - 60
os.utime(sb.state_file, (past, past))
sb.start()
assert sb.calls()[-1] == ENABLE
assert "over the 30s window" in sb.log()
def test_unreadable_state_file_is_ignored(sb: Sandbox) -> None:
sb.state_file.write_text("{not json")
sb.start()
assert sb.calls() == [ENABLE]
assert "Ignoring unreadable state file" in sb.log()
assert json.loads(sb.state_file.read_text())["cookies"] == []
# --- startup and cleanup -------------------------------------------------------
def test_exits_2_if_name_is_owned(sb: Sandbox) -> None:
other = sb.client()
do_not_queue = 4
reply = other.call(
"RequestName", GLib.Variant("(su)", (BUS_NAME, do_not_queue)), "(u)",
"/org/freedesktop/DBus", "org.freedesktop.DBus", "org.freedesktop.DBus",
)
assert reply == (1,) # primary owner
proc = sb.start(wait=False)
assert proc.wait(TIMEOUT) == 2
assert sb.calls() == []
assert not sb.state_file.exists()
@pytest.mark.parametrize(("backend", "xautolock_installed", "expected"), [
("xautolock", True, [ENABLE]),
("'xss-lock'", True, []),
(None, True, [ENABLE]), # both installed: xautolock
(None, False, []), # only xss-lock
("bogus", False, []),
])
def test_cleanup(sb: Sandbox, backend: str | None, xautolock_installed: bool, expected: list[str]) -> None:
sb.set_backend(backend)
if not xautolock_installed:
(sb.bin / "xautolock").unlink()
result = subprocess.run(
[sys.executable, str(SCRIPT), "--cleanup"], env=sb.environ(sb.bus),
capture_output=True, text=True, timeout=TIMEOUT,
)
assert result.returncode == 0
assert sb.calls() == expected
if backend == "bogus":
assert "unknown backend 'bogus'" in result.stdout
Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Dim the screen until killed, as an xss-lock notifier: xss-lock -n xidle-dim -- slock.
Maps a black window over the whole screen and fades in its opacity through the compositor; gamma is left alone,
so it doesn't clash with redshift. Without a compositor it does nothing, as the window would be solid black.
"""
import argparse
import ctypes
import signal
import sys
import time
from types import FrameType
CW_BACK_PIXEL = 1 << 1
CW_OVERRIDE_REDIRECT = 1 << 9
INPUT_OUTPUT = 1
XA_CARDINAL = 6
PROP_MODE_REPLACE = 0
OPACITY_MAX = 0xFFFFFFFF
class XSetWindowAttributes(ctypes.Structure):
_fields_ = [
("background_pixmap", ctypes.c_ulong),
("background_pixel", ctypes.c_ulong),
("border_pixmap", ctypes.c_ulong),
("border_pixel", ctypes.c_ulong),
("bit_gravity", ctypes.c_int),
("win_gravity", ctypes.c_int),
("backing_store", ctypes.c_int),
("backing_planes", ctypes.c_ulong),
("backing_pixel", ctypes.c_ulong),
("save_under", ctypes.c_int),
("event_mask", ctypes.c_long),
("do_not_propagate_mask", ctypes.c_long),
("override_redirect", ctypes.c_int),
("colormap", ctypes.c_ulong),
("cursor", ctypes.c_ulong),
]
def load_x11() -> ctypes.CDLL:
x11 = ctypes.CDLL("libX11.so.6")
p, ul, i = ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
x11.XOpenDisplay.restype = p
for name in ("XDefaultScreen",):
getattr(x11, name).argtypes = [p]
for name in ("XRootWindow", "XBlackPixel"):
getattr(x11, name).argtypes = [p, i]
getattr(x11, name).restype = ul
for name in ("XDisplayWidth", "XDisplayHeight"):
getattr(x11, name).argtypes = [p, i]
x11.XInternAtom.argtypes = [p, ctypes.c_char_p, i]
x11.XInternAtom.restype = ul
x11.XGetSelectionOwner.argtypes = [p, ul]
x11.XGetSelectionOwner.restype = ul
x11.XCreateWindow.argtypes = [p, ul, i, i, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, i, ctypes.c_uint, p, ul,
ctypes.POINTER(XSetWindowAttributes)]
x11.XCreateWindow.restype = ul
x11.XChangeProperty.argtypes = [p, ul, ul, ul, i, i, p, i]
x11.XMapRaised.argtypes = [p, ul]
x11.XFlush.argtypes = [p]
return x11
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--opacity", type=float, default=0.6, help="final darkness, 0 to 1 (default: %(default)s)")
parser.add_argument("--fade", type=float, default=1.5, help="fade-in time in seconds (default: %(default)s)")
args = parser.parse_args()
if not 0 <= args.opacity <= 1 or args.fade < 0:
parser.error("--opacity must be between 0 and 1 and --fade not negative")
return args
def exit_on_signal(_signum: int, _frame: FrameType | None) -> None:
sys.exit(0) # the X server removes the window when the connection closes
def main() -> None:
args = parse_args()
# xss-lock sends SIGHUP on user activity and SIGTERM when the locker starts.
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT):
signal.signal(sig, exit_on_signal)
x11 = load_x11()
dpy = x11.XOpenDisplay(None)
if not dpy:
sys.exit("xidle-dim: cannot open display")
screen = x11.XDefaultScreen(dpy)
if not x11.XGetSelectionOwner(dpy, x11.XInternAtom(dpy, f"_NET_WM_CM_S{screen}".encode(), False)):
print("xidle-dim: no compositor running, not dimming", file=sys.stderr)
signal.pause() # stay alive like a notifier should, until xss-lock kills us
attrs = XSetWindowAttributes(background_pixel=x11.XBlackPixel(dpy, screen), override_redirect=True)
win = x11.XCreateWindow(dpy, x11.XRootWindow(dpy, screen), 0, 0, x11.XDisplayWidth(dpy, screen),
x11.XDisplayHeight(dpy, screen), 0, 0, INPUT_OUTPUT, None,
CW_BACK_PIXEL | CW_OVERRIDE_REDIRECT, ctypes.byref(attrs))
opacity_atom = x11.XInternAtom(dpy, b"_NET_WM_WINDOW_OPACITY", False)
def set_opacity(level: float) -> None:
value = ctypes.c_ulong(int(level * OPACITY_MAX)) # format 32 properties are passed as longs
x11.XChangeProperty(dpy, win, opacity_atom, XA_CARDINAL, 32, PROP_MODE_REPLACE, ctypes.byref(value), 1)
x11.XFlush(dpy)
set_opacity(0)
x11.XMapRaised(dpy, win)
steps = max(1, int(args.fade * 30))
for n in range(1, steps + 1):
set_opacity(args.opacity * n / steps)
time.sleep(args.fade / steps)
while True:
signal.pause()
if __name__ == "__main__":
main()
-14
View File
@@ -31,7 +31,6 @@ OBJECT_PATHS = ("/org/freedesktop/ScreenSaver", "/ScreenSaver") # Vivaldi uses
EXIT_NAME_TAKEN = 2 EXIT_NAME_TAKEN = 2
ACTIVITY_QUIET = 300 # seconds without SimulateUserActivity that end a logged burst ACTIVITY_QUIET = 300 # seconds without SimulateUserActivity that end a logged burst
STATE_FILE = "xidle-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 STATE_HEARTBEAT = 10 # seconds between state file touches while inhibits are held
CONFIG_FILE = "xidle-inhibit/config" # in $XDG_CONFIG_HOME CONFIG_FILE = "xidle-inhibit/config" # in $XDG_CONFIG_HOME
BACKENDS = ("xautolock", "xss-lock") BACKENDS = ("xautolock", "xss-lock")
@@ -228,14 +227,6 @@ class Inhibitor:
self.restore() self.restore()
self.state_ready = True self.state_ready = True
self.save() 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(): if self.active_count():
self.block() self.block()
elif self.xautolock: elif self.xautolock:
@@ -248,9 +239,6 @@ class Inhibitor:
return return
assert self.conn is not None assert self.conn is not None
path = self.state_path 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: try:
with open(path) as f: with open(path) as f:
state = json.load(f) state = json.load(f)
@@ -263,8 +251,6 @@ class Inhibitor:
except (OSError, ValueError, KeyError, TypeError) as e: except (OSError, ValueError, KeyError, TypeError) as e:
log(f"Ignoring unreadable state file {path}: {e}") log(f"Ignoring unreadable state file {path}: {e}")
return return
if path == old:
log(f"Reading inhibits from {old} (xautolock-inhibit)")
if bus_id != self.conn.get_guid(): if bus_id != self.conn.get_guid():
# Unique names like :1.42 are reused by a new bus, so they would mean other clients. # Unique names like :1.42 are reused by a new bus, so they would mean other clients.
if saved: if saved: